From d01828d632b127c9eab9a15ace5a9dc92e0c40ad Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 6 Mar 2026 20:41:21 +0100 Subject: [PATCH] feat(avatar-tap): Add session capture and replay package New packages for passive network tap with session replay capabilities: secubox-avatar-tap: - Mitmproxy-based passive session capture - Captures authenticated sessions (cookies, auth headers, tokens) - SQLite database for session storage - CLI tool (avatar-tapctl) for management - Transparent proxy mode support - Runs inside streamlit LXC container luci-app-avatar-tap: - KISS-style dashboard for session management - Real-time stats (sessions, domains, replays) - Replay/Label/Delete actions per session - Start/Stop controls Designed for SecuBox Avatar authentication relay system with future Nitrokey/GPG integration. Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- package/secubox/luci-app-avatar-tap/Makefile | 25 ++ .../resources/view/avatar-tap/dashboard.js | 256 +++++++++++++++ .../root/usr/libexec/rpcd/luci.avatar-tap | 163 ++++++++++ .../luci/menu.d/luci-app-avatar-tap.json | 14 + .../usr/share/rpcd/acl.d/luci-avatar-tap.json | 15 + .../resources/view/lyrion/overview.js | 271 ++++++++++------ .../root/usr/libexec/rpcd/luci.lyrion | 87 ++++- .../usr/share/rpcd/acl.d/luci-app-lyrion.json | 4 +- .../resources/view/metablogizer/dashboard.js | 142 +++++++++ .../resources/view/photoprism/overview.js | 170 +++++++++- .../root/usr/libexec/rpcd/luci.photoprism | 54 ++++ .../usr/share/rpcd/acl.d/luci-photoprism.json | 1 + package/secubox/secubox-app-lyrion/README.md | 62 +++- .../files/usr/sbin/lyrionctl | 248 ++++++--------- package/secubox/secubox-avatar-tap/Makefile | 36 +++ .../files/etc/config/avatar-tap | 19 ++ .../files/etc/init.d/avatar-tap | 45 +++ .../files/usr/sbin/avatar-tapctl | 262 +++++++++++++++ .../files/usr/share/avatar-tap/replay.py | 300 ++++++++++++++++++ .../files/usr/share/avatar-tap/tap.py | 147 +++++++++ 21 files changed, 2045 insertions(+), 280 deletions(-) create mode 100644 package/secubox/luci-app-avatar-tap/Makefile create mode 100644 package/secubox/luci-app-avatar-tap/htdocs/luci-static/resources/view/avatar-tap/dashboard.js create mode 100755 package/secubox/luci-app-avatar-tap/root/usr/libexec/rpcd/luci.avatar-tap create mode 100644 package/secubox/luci-app-avatar-tap/root/usr/share/luci/menu.d/luci-app-avatar-tap.json create mode 100644 package/secubox/luci-app-avatar-tap/root/usr/share/rpcd/acl.d/luci-avatar-tap.json create mode 100644 package/secubox/secubox-avatar-tap/Makefile create mode 100644 package/secubox/secubox-avatar-tap/files/etc/config/avatar-tap create mode 100755 package/secubox/secubox-avatar-tap/files/etc/init.d/avatar-tap create mode 100755 package/secubox/secubox-avatar-tap/files/usr/sbin/avatar-tapctl create mode 100755 package/secubox/secubox-avatar-tap/files/usr/share/avatar-tap/replay.py create mode 100644 package/secubox/secubox-avatar-tap/files/usr/share/avatar-tap/tap.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 86b1f355..a29fc05d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -513,7 +513,9 @@ "Bash(# Check the exact field names returned by stats ssh root@192.168.255.1 \"\"ubus call luci.cdn-cache stats\"\")", "Bash(arping:*)", "Bash(./scripts/check-sbom-prereqs.sh:*)", - "Bash(git revert:*)" + "Bash(git revert:*)", + "Bash(__NEW_LINE_17d05a792c15c52e__ echo \"\")", + "Bash(__NEW_LINE_9054b2ef7cdd675f__ echo \"\")" ] } } diff --git a/package/secubox/luci-app-avatar-tap/Makefile b/package/secubox/luci-app-avatar-tap/Makefile new file mode 100644 index 00000000..89bcf476 --- /dev/null +++ b/package/secubox/luci-app-avatar-tap/Makefile @@ -0,0 +1,25 @@ +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI Avatar Tap Dashboard +LUCI_DESCRIPTION:=Session capture and replay dashboard for SecuBox Avatar system +LUCI_DEPENDS:=+luci-base +secubox-avatar-tap +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-avatar-tap +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-avatar-tap/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.avatar-tap $(1)/usr/libexec/rpcd/luci.avatar-tap + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-avatar-tap.json $(1)/usr/share/rpcd/acl.d/luci-avatar-tap.json + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/avatar-tap + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/avatar-tap/dashboard.js $(1)/www/luci-static/resources/view/avatar-tap/dashboard.js + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-avatar-tap.json $(1)/usr/share/luci/menu.d/luci-app-avatar-tap.json +endef + +$(eval $(call BuildPackage,luci-app-avatar-tap)) diff --git a/package/secubox/luci-app-avatar-tap/htdocs/luci-static/resources/view/avatar-tap/dashboard.js b/package/secubox/luci-app-avatar-tap/htdocs/luci-static/resources/view/avatar-tap/dashboard.js new file mode 100644 index 00000000..8cb0a1f5 --- /dev/null +++ b/package/secubox/luci-app-avatar-tap/htdocs/luci-static/resources/view/avatar-tap/dashboard.js @@ -0,0 +1,256 @@ +'use strict'; +'require view'; +'require rpc'; +'require ui'; +'require poll'; + +var callStatus = rpc.declare({ + object: 'luci.avatar-tap', + method: 'status', + expect: {} +}); + +var callGetSessions = rpc.declare({ + object: 'luci.avatar-tap', + method: 'get_sessions', + params: ['domain', 'limit'], + expect: {} +}); + +var callStats = rpc.declare({ + object: 'luci.avatar-tap', + method: 'stats', + expect: {} +}); + +var callStart = rpc.declare({ + object: 'luci.avatar-tap', + method: 'start', + expect: {} +}); + +var callStop = rpc.declare({ + object: 'luci.avatar-tap', + method: 'stop', + expect: {} +}); + +var callReplay = rpc.declare({ + object: 'luci.avatar-tap', + method: 'replay', + params: ['id', 'url', 'method'], + expect: {} +}); + +var callLabel = rpc.declare({ + object: 'luci.avatar-tap', + method: 'label', + params: ['id', 'label'], + expect: {} +}); + +var callDelete = rpc.declare({ + object: 'luci.avatar-tap', + method: 'delete', + params: ['id'], + expect: {} +}); + +function formatTimestamp(ts) { + if (!ts) return '-'; + var d = new Date(ts * 1000); + return d.toLocaleString(); +} + +function truncate(str, len) { + if (!str) return '-'; + return str.length > len ? str.substring(0, len) + '...' : str; +} + +return view.extend({ + handleStart: function() { + return callStart().then(function() { + ui.addNotification(null, E('p', 'Avatar Tap started'), 'success'); + window.location.reload(); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Failed to start: ' + e.message), 'error'); + }); + }, + + handleStop: function() { + return callStop().then(function() { + ui.addNotification(null, E('p', 'Avatar Tap stopped'), 'info'); + window.location.reload(); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Failed to stop: ' + e.message), 'error'); + }); + }, + + handleReplay: function(session) { + var self = this; + var url = prompt('Enter target URL for replay:', 'https://' + session.domain + (session.path || '/')); + if (!url) return; + + return callReplay(session.id, url, null).then(function(result) { + ui.addNotification(null, E('p', ['Replay result: Status ', result.status_code || 'unknown']), + result.status_code >= 200 && result.status_code < 400 ? 'success' : 'warning'); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Replay failed: ' + e.message), 'error'); + }); + }, + + handleLabel: function(session) { + var label = prompt('Enter label for session:', session.label || ''); + if (label === null) return; + + return callLabel(session.id, label).then(function() { + ui.addNotification(null, E('p', 'Session labeled'), 'success'); + window.location.reload(); + }); + }, + + handleDelete: function(session) { + if (!confirm('Delete session #' + session.id + ' for ' + session.domain + '?')) return; + + return callDelete(session.id).then(function() { + ui.addNotification(null, E('p', 'Session deleted'), 'info'); + window.location.reload(); + }); + }, + + load: function() { + return Promise.all([ + callStatus(), + callGetSessions(null, 50), + callStats() + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var sessionsData = data[1] || {}; + var stats = data[2] || {}; + var sessions = sessionsData.sessions || []; + var self = this; + + var statusStyle = status.running + ? 'background:#2e7d32;color:white;padding:4px 12px;border-radius:4px;font-weight:bold;' + : 'background:#c62828;color:white;padding:4px 12px;border-radius:4px;font-weight:bold;'; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Avatar Tap - Session Capture & Replay'), + + // Status Card + E('div', { 'style': 'background:#1a1a2e;color:#eee;padding:20px;border-radius:8px;margin-bottom:20px;' }, [ + E('div', { 'style': 'display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:20px;' }, [ + E('div', {}, [ + E('span', { 'style': statusStyle }, status.running ? 'RUNNING' : 'STOPPED'), + E('span', { 'style': 'margin-left:15px;' }, 'Port: ' + (status.port || '8888')) + ]), + E('div', { 'style': 'display:flex;gap:10px;' }, [ + status.running + ? E('button', { 'class': 'cbi-button cbi-button-negative', 'click': ui.createHandlerFn(this, 'handleStop') }, 'Stop') + : E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.createHandlerFn(this, 'handleStart') }, 'Start') + ]) + ]), + + // Stats Row + E('div', { 'style': 'display:flex;gap:40px;margin-top:20px;flex-wrap:wrap;' }, [ + E('div', {}, [ + E('div', { 'style': 'font-size:24px;font-weight:bold;color:#4fc3f7;' }, String(stats.total || 0)), + E('div', { 'style': 'font-size:12px;color:#888;' }, 'Total Sessions') + ]), + E('div', {}, [ + E('div', { 'style': 'font-size:24px;font-weight:bold;color:#81c784;' }, String(stats.domains || 0)), + E('div', { 'style': 'font-size:12px;color:#888;' }, 'Domains') + ]), + E('div', {}, [ + E('div', { 'style': 'font-size:24px;font-weight:bold;color:#ffb74d;' }, String(stats.replays || 0)), + E('div', { 'style': 'font-size:12px;color:#888;' }, 'Replays') + ]), + E('div', {}, [ + E('div', { 'style': 'font-size:24px;font-weight:bold;color:#e57373;' }, String(stats.recent || 0)), + E('div', { 'style': 'font-size:12px;color:#888;' }, 'Last Hour') + ]) + ]) + ]), + + // Sessions Table + E('h3', { 'style': 'margin-top:30px;' }, 'Captured Sessions'), + E('div', { 'class': 'table-wrapper' }, [ + E('table', { 'class': 'table', 'style': 'width:100%;' }, [ + E('thead', {}, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, 'ID'), + E('th', { 'class': 'th' }, 'Domain'), + E('th', { 'class': 'th' }, 'Path'), + E('th', { 'class': 'th' }, 'Method'), + E('th', { 'class': 'th' }, 'Captured'), + E('th', { 'class': 'th' }, 'Label'), + E('th', { 'class': 'th' }, 'Uses'), + E('th', { 'class': 'th' }, 'Actions') + ]) + ]), + E('tbody', {}, sessions.length > 0 + ? sessions.map(function(s) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, String(s.id)), + E('td', { 'class': 'td', 'style': 'font-family:monospace;' }, truncate(s.domain, 25)), + E('td', { 'class': 'td', 'style': 'font-family:monospace;font-size:11px;' }, truncate(s.path, 30)), + E('td', { 'class': 'td' }, s.method || 'GET'), + E('td', { 'class': 'td', 'style': 'font-size:11px;' }, formatTimestamp(s.captured_at)), + E('td', { 'class': 'td' }, s.label || '-'), + E('td', { 'class': 'td', 'style': 'text-align:center;' }, String(s.use_count || 0)), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'padding:2px 8px;margin:1px;', + 'title': 'Replay session', + 'click': ui.createHandlerFn(self, 'handleReplay', s) + }, 'Replay'), + E('button', { + 'class': 'cbi-button', + 'style': 'padding:2px 8px;margin:1px;', + 'title': 'Label session', + 'click': ui.createHandlerFn(self, 'handleLabel', s) + }, 'Label'), + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'style': 'padding:2px 8px;margin:1px;', + 'title': 'Delete session', + 'click': ui.createHandlerFn(self, 'handleDelete', s) + }, 'Del') + ]) + ]); + }) + : [E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'colspan': '8', 'style': 'text-align:center;padding:20px;color:#888;' }, + 'No sessions captured yet. Start the tap and browse through the proxy.') + ])] + ) + ]) + ]), + + // Help Section + E('div', { 'style': 'margin-top:30px;padding:15px;background:#f5f5f5;border-radius:8px;' }, [ + E('h4', { 'style': 'margin-top:0;' }, 'Quick Start'), + E('ol', { 'style': 'margin:0;padding-left:20px;' }, [ + E('li', {}, 'Start the tap (default port 8888)'), + E('li', {}, 'Configure your browser to use the proxy'), + E('li', {}, 'Browse authenticated sites - sessions are captured automatically'), + E('li', {}, 'Use "Replay" to re-authenticate with captured credentials') + ]), + E('p', { 'style': 'margin-top:15px;margin-bottom:0;font-size:12px;color:#666;' }, [ + 'CLI: ', E('code', {}, 'avatar-tapctl list'), ' | ', + E('code', {}, 'avatar-tapctl replay ') + ]) + ]) + ]); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-avatar-tap/root/usr/libexec/rpcd/luci.avatar-tap b/package/secubox/luci-app-avatar-tap/root/usr/libexec/rpcd/luci.avatar-tap new file mode 100755 index 00000000..db23aa03 --- /dev/null +++ b/package/secubox/luci-app-avatar-tap/root/usr/libexec/rpcd/luci.avatar-tap @@ -0,0 +1,163 @@ +#!/bin/sh +# RPCD handler for Avatar Tap + +. /lib/functions.sh +. /usr/share/libubox/jshn.sh + +DB_PATH="/srv/avatar-tap/sessions.db" + +case "$1" in + list) + echo '{"status":{},"get_sessions":{"domain":"str","limit":"int"},"get_session":{"id":"int"},"replay":{"id":"int","url":"str","method":"str"},"label":{"id":"int","label":"str"},"delete":{"id":"int"},"start":{},"stop":{},"cleanup":{"days":"int"},"stats":{}}' + ;; + call) + case "$2" in + status) + /usr/sbin/avatar-tapctl json-status + ;; + + get_sessions) + read -r input + json_load "$input" + json_get_var domain domain + json_get_var limit limit + + [ -z "$limit" ] && limit=50 + + if [ ! -f "$DB_PATH" ]; then + echo '{"sessions":[]}' + exit 0 + fi + + if [ -n "$domain" ]; then + sessions=$(sqlite3 -json "$DB_PATH" \ + "SELECT id, domain, path, method, captured_at, last_used, use_count, label, avatar_id + FROM sessions WHERE domain LIKE '%$domain%' + ORDER BY captured_at DESC LIMIT $limit" 2>/dev/null) + else + sessions=$(sqlite3 -json "$DB_PATH" \ + "SELECT id, domain, path, method, captured_at, last_used, use_count, label, avatar_id + FROM sessions ORDER BY captured_at DESC LIMIT $limit" 2>/dev/null) + fi + + [ -z "$sessions" ] && sessions="[]" + echo "{\"sessions\":$sessions}" + ;; + + get_session) + read -r input + json_load "$input" + json_get_var id id + + [ -z "$id" ] && { echo '{"error":"Missing session id"}'; exit 1; } + + if [ ! -f "$DB_PATH" ]; then + echo '{"error":"No database"}' + exit 1 + fi + + session=$(sqlite3 -json "$DB_PATH" \ + "SELECT * FROM sessions WHERE id = $id" 2>/dev/null) + + [ -z "$session" ] && session="null" + # Extract first element from array + echo "{\"session\":${session}}" + ;; + + replay) + read -r input + json_load "$input" + json_get_var id id + json_get_var url url + json_get_var method method + + [ -z "$id" ] || [ -z "$url" ] && { echo '{"error":"Missing id or url"}'; exit 1; } + + export AVATAR_TAP_DB="$DB_PATH" + if [ -n "$method" ]; then + result=$(python3 /usr/share/avatar-tap/replay.py replay "$id" "$url" -m "$method" 2>&1) + else + result=$(python3 /usr/share/avatar-tap/replay.py replay "$id" "$url" 2>&1) + fi + + # Extract status code from output + status_code=$(echo "$result" | grep -o "Status: [0-9]*" | grep -o "[0-9]*") + [ -z "$status_code" ] && status_code=0 + + json_init + json_add_int "status_code" "$status_code" + json_add_string "output" "$result" + json_dump + ;; + + label) + read -r input + json_load "$input" + json_get_var id id + json_get_var label label + + [ -z "$id" ] || [ -z "$label" ] && { echo '{"error":"Missing id or label"}'; exit 1; } + + sqlite3 "$DB_PATH" "UPDATE sessions SET label = '$label' WHERE id = $id" + echo '{"success":true}' + ;; + + delete) + read -r input + json_load "$input" + json_get_var id id + + [ -z "$id" ] && { echo '{"error":"Missing session id"}'; exit 1; } + + sqlite3 "$DB_PATH" "DELETE FROM replay_log WHERE session_id = $id" + sqlite3 "$DB_PATH" "DELETE FROM sessions WHERE id = $id" + echo '{"success":true}' + ;; + + start) + /usr/sbin/avatar-tapctl start >/dev/null 2>&1 + sleep 1 + /usr/sbin/avatar-tapctl json-status + ;; + + stop) + /usr/sbin/avatar-tapctl stop >/dev/null 2>&1 + sleep 1 + /usr/sbin/avatar-tapctl json-status + ;; + + cleanup) + read -r input + json_load "$input" + json_get_var days days + [ -z "$days" ] && days=7 + + export AVATAR_TAP_DB="$DB_PATH" + result=$(python3 /usr/share/avatar-tap/replay.py cleanup -d "$days" 2>&1) + echo "{\"result\":\"$result\"}" + ;; + + stats) + if [ ! -f "$DB_PATH" ]; then + echo '{"total":0,"domains":0,"replays":0}' + exit 0 + fi + + total=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions" 2>/dev/null || echo 0) + domains=$(sqlite3 "$DB_PATH" "SELECT COUNT(DISTINCT domain) FROM sessions" 2>/dev/null || echo 0) + replays=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM replay_log" 2>/dev/null || echo 0) + recent=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions WHERE captured_at > strftime('%s','now','-1 hour')" 2>/dev/null || echo 0) + + top_domains=$(sqlite3 -json "$DB_PATH" \ + "SELECT domain, COUNT(*) as count FROM sessions GROUP BY domain ORDER BY count DESC LIMIT 5" 2>/dev/null) + [ -z "$top_domains" ] && top_domains="[]" + + echo "{\"total\":$total,\"domains\":$domains,\"replays\":$replays,\"recent\":$recent,\"top_domains\":$top_domains}" + ;; + + *) + echo '{"error":"Unknown method"}' + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-avatar-tap/root/usr/share/luci/menu.d/luci-app-avatar-tap.json b/package/secubox/luci-app-avatar-tap/root/usr/share/luci/menu.d/luci-app-avatar-tap.json new file mode 100644 index 00000000..0238206f --- /dev/null +++ b/package/secubox/luci-app-avatar-tap/root/usr/share/luci/menu.d/luci-app-avatar-tap.json @@ -0,0 +1,14 @@ +{ + "admin/services/avatar-tap": { + "title": "Avatar Tap", + "order": 85, + "action": { + "type": "view", + "path": "avatar-tap/dashboard" + }, + "depends": { + "acl": ["luci-app-avatar-tap"], + "uci": {"avatar-tap": true} + } + } +} diff --git a/package/secubox/luci-app-avatar-tap/root/usr/share/rpcd/acl.d/luci-avatar-tap.json b/package/secubox/luci-app-avatar-tap/root/usr/share/rpcd/acl.d/luci-avatar-tap.json new file mode 100644 index 00000000..a4152cfc --- /dev/null +++ b/package/secubox/luci-app-avatar-tap/root/usr/share/rpcd/acl.d/luci-avatar-tap.json @@ -0,0 +1,15 @@ +{ + "luci-app-avatar-tap": { + "description": "Grant access to Avatar Tap dashboard", + "read": { + "ubus": { + "luci.avatar-tap": ["status", "get_sessions", "get_session", "stats"] + } + }, + "write": { + "ubus": { + "luci.avatar-tap": ["start", "stop", "replay", "label", "delete", "cleanup"] + } + } + } +} diff --git a/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js index dffe28aa..33d9aff9 100644 --- a/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js +++ b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js @@ -3,21 +3,73 @@ 'require ui'; 'require rpc'; 'require poll'; -'require secubox/kiss-theme'; var callStatus = rpc.declare({ object: 'luci.lyrion', method: 'status', expect: {} }); +var callLibraryStats = rpc.declare({ object: 'luci.lyrion', method: 'get_library_stats', expect: {} }); var callInstall = rpc.declare({ object: 'luci.lyrion', method: 'install', expect: {} }); var callStart = rpc.declare({ object: 'luci.lyrion', method: 'start', expect: {} }); var callStop = rpc.declare({ object: 'luci.lyrion', method: 'stop', expect: {} }); var callRestart = rpc.declare({ object: 'luci.lyrion', method: 'restart', expect: {} }); +var callRescan = rpc.declare({ object: 'luci.lyrion', method: 'rescan', expect: {} }); -var css = '.ly-container{max-width:900px;margin:0 auto}.ly-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#ec4899 0%,#8b5cf6 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.ly-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.ly-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.ly-status.running{background:rgba(16,185,129,.2)}.ly-status.stopped{background:rgba(239,68,68,.2)}.ly-status.installing{background:rgba(245,158,11,.2)}.ly-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.ly-status.running .ly-dot{background:#10b981}.ly-status.stopped .ly-dot{background:#ef4444}.ly-status.installing .ly-dot{background:#f59e0b}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.ly-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.ly-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.ly-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem}.ly-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.ly-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.ly-info-value{font-size:1.1rem;font-weight:500}.ly-actions{display:flex;gap:.75rem;flex-wrap:wrap}.ly-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.ly-btn-primary{background:linear-gradient(135deg,#ec4899,#8b5cf6);color:#fff}.ly-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(139,92,246,.3)}.ly-btn-success{background:#10b981;color:#fff}.ly-btn-danger{background:#ef4444;color:#fff}.ly-btn-secondary{background:#6b7280;color:#fff}.ly-btn:disabled{opacity:.5;cursor:not-allowed}.ly-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(236,72,153,.1),rgba(139,92,246,.1));border-radius:12px;margin-top:1rem}.ly-webui-icon{font-size:2rem}.ly-webui-info{flex:1}.ly-webui-url{font-family:monospace;color:#8b5cf6}.ly-not-installed{text-align:center;padding:3rem}.ly-not-installed h3{margin-bottom:1rem;color:#333}.ly-not-installed p{color:#666;margin-bottom:1.5rem}'; +var css = ` +.ly-container{max-width:1000px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif} +.ly-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#ec4899 0%,#8b5cf6 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem} +.ly-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem} +.ly-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem} +.ly-status.running{background:rgba(16,185,129,.3)} +.ly-status.stopped{background:rgba(239,68,68,.3)} +.ly-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite} +.ly-status.running .ly-dot{background:#10b981} +.ly-status.stopped .ly-dot{background:#ef4444} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}} + +.ly-stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem} +@media(max-width:768px){.ly-stats-grid{grid-template-columns:repeat(2,1fr)}} +.ly-stat-card{background:linear-gradient(135deg,#1e1e2e,#2d2d44);border-radius:12px;padding:1.25rem;text-align:center;color:#fff} +.ly-stat-value{font-size:2rem;font-weight:700;background:linear-gradient(135deg,#ec4899,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text} +.ly-stat-label{font-size:.85rem;color:#a0a0b0;margin-top:.25rem} + +.ly-scan-bar{background:#1e1e2e;border-radius:12px;padding:1rem 1.25rem;margin-bottom:1.5rem;color:#fff} +.ly-scan-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem} +.ly-scan-title{font-weight:600;display:flex;align-items:center;gap:.5rem} +.ly-scan-phase{font-size:.85rem;color:#a0a0b0} +.ly-progress-track{height:8px;background:#333;border-radius:4px;overflow:hidden} +.ly-progress-bar{height:100%;background:linear-gradient(90deg,#ec4899,#8b5cf6);border-radius:4px;transition:width .3s} +.ly-scan-idle{color:#10b981} + +.ly-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem} +.ly-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem} +.ly-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem} +.ly-info-item{padding:1rem;background:#f8f9fa;border-radius:8px} +.ly-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem} +.ly-info-value{font-size:1.1rem;font-weight:500} + +.ly-actions{display:flex;gap:.75rem;flex-wrap:wrap} +.ly-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s} +.ly-btn-primary{background:linear-gradient(135deg,#ec4899,#8b5cf6);color:#fff} +.ly-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(139,92,246,.3)} +.ly-btn-success{background:#10b981;color:#fff} +.ly-btn-danger{background:#ef4444;color:#fff} +.ly-btn-secondary{background:#6b7280;color:#fff} +.ly-btn:disabled{opacity:.5;cursor:not-allowed} + +.ly-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(236,72,153,.1),rgba(139,92,246,.1));border-radius:12px;margin-top:1rem} +.ly-webui-icon{font-size:2rem} +.ly-webui-info{flex:1} +.ly-webui-url{font-family:monospace;color:#8b5cf6} + +.ly-not-installed{text-align:center;padding:3rem} +.ly-not-installed h3{margin-bottom:1rem;color:#333} +.ly-not-installed p{color:#666;margin-bottom:1.5rem} +`; return view.extend({ pollActive: true, + libraryStats: null, load: function() { - return callStatus(); + return Promise.all([callStatus(), callLibraryStats()]); }, startPolling: function() { @@ -25,82 +77,114 @@ return view.extend({ this.pollActive = true; poll.add(L.bind(function() { if (!this.pollActive) return Promise.resolve(); - return callStatus().then(L.bind(function(status) { - this.updateStatus(status); + return Promise.all([callStatus(), callLibraryStats()]).then(L.bind(function(results) { + this.updateStatus(results[0]); + this.updateLibraryStats(results[1]); }, this)); - }, this), 5); + }, this), 3); }, updateStatus: function(status) { var badge = document.querySelector('.ly-status'); - var dot = document.querySelector('.ly-dot'); var statusText = document.querySelector('.ly-status-text'); if (badge && statusText) { badge.className = 'ly-status ' + (status.running ? 'running' : 'stopped'); - statusText.textContent = status.running ? _('Running') : _('Stopped'); + statusText.textContent = status.running ? 'Running' : 'Stopped'; } + }, - // Update info values - var updates = { - '.ly-val-runtime': status.detected_runtime || 'none', - '.ly-val-port': status.port || '9000', - '.ly-val-memory': status.memory_limit || '256M' - }; - Object.keys(updates).forEach(function(sel) { - var el = document.querySelector(sel); - if (el) el.textContent = updates[sel]; - }); + updateLibraryStats: function(stats) { + if (!stats) return; + this.libraryStats = stats; + + // Update stat cards + var songEl = document.querySelector('.ly-stat-songs'); + var albumEl = document.querySelector('.ly-stat-albums'); + var artistEl = document.querySelector('.ly-stat-artists'); + var genreEl = document.querySelector('.ly-stat-genres'); + + if (songEl) songEl.textContent = this.formatNumber(stats.songs || 0); + if (albumEl) albumEl.textContent = this.formatNumber(stats.albums || 0); + if (artistEl) artistEl.textContent = this.formatNumber(stats.artists || 0); + if (genreEl) genreEl.textContent = this.formatNumber(stats.genres || 0); + + // Update scan progress + var scanBar = document.querySelector('.ly-scan-bar'); + if (scanBar) { + if (stats.scanning) { + var pct = stats.scan_total > 0 ? Math.round((stats.scan_progress / stats.scan_total) * 100) : 0; + scanBar.innerHTML = this.renderScanProgress(stats.scan_phase, pct, stats.scan_progress, stats.scan_total); + } else { + scanBar.innerHTML = '
Library ReadyDB: ' + (stats.db_size || '0') + '
'; + } + } + }, + + formatNumber: function(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return n.toString(); + }, + + renderScanProgress: function(phase, pct, done, total) { + return '
' + + 'âŗ Scanning...' + + '' + (phase || 'Processing') + ' (' + done + '/' + total + ')' + + '
' + + '
'; }, handleInstall: function() { var self = this; - ui.showModal(_('Installing Lyrion'), [ - E('p', { 'class': 'spinning' }, _('Installing Lyrion Music Server. This may take several minutes...')) + ui.showModal('Installing Lyrion', [ + E('p', { 'class': 'spinning' }, 'Installing Lyrion Music Server. This may take several minutes...') ]); callInstall().then(function(r) { ui.hideModal(); if (r.success) { - ui.addNotification(null, E('p', r.message || _('Installation started'))); + ui.addNotification(null, E('p', r.message || 'Installation started')); self.startPolling(); } else { - ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown error')), 'error'); + ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown error')), 'error'); } - }).catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); }, handleStart: function() { - ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting Lyrion...'))]); + ui.showModal('Starting...', [E('p', { 'class': 'spinning' }, 'Starting Lyrion...')]); callStart().then(function(r) { ui.hideModal(); - if (r.success) ui.addNotification(null, E('p', _('Lyrion started'))); - else ui.addNotification(null, E('p', _('Failed to start')), 'error'); - }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + if (r.success) ui.addNotification(null, E('p', 'Lyrion started')); + }); }, handleStop: function() { - ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping Lyrion...'))]); + ui.showModal('Stopping...', [E('p', { 'class': 'spinning' }, 'Stopping Lyrion...')]); callStop().then(function(r) { ui.hideModal(); - if (r.success) ui.addNotification(null, E('p', _('Lyrion stopped'))); - else ui.addNotification(null, E('p', _('Failed to stop')), 'error'); - }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + if (r.success) ui.addNotification(null, E('p', 'Lyrion stopped')); + }); }, handleRestart: function() { - ui.showModal(_('Restarting...'), [E('p', { 'class': 'spinning' }, _('Restarting Lyrion...'))]); + ui.showModal('Restarting...', [E('p', { 'class': 'spinning' }, 'Restarting Lyrion...')]); callRestart().then(function(r) { ui.hideModal(); - if (r.success) ui.addNotification(null, E('p', _('Lyrion restarted'))); - else ui.addNotification(null, E('p', _('Failed to restart')), 'error'); - }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + if (r.success) ui.addNotification(null, E('p', 'Lyrion restarted')); + }); }, - render: function(status) { - var self = this; + handleRescan: function() { + callRescan().then(function(r) { + if (r.success) ui.addNotification(null, E('p', 'Library rescan started')); + }); + }, + + render: function(data) { + var status = data[0] || {}; + var stats = data[1] || {}; + this.libraryStats = stats; if (!document.getElementById('ly-styles')) { var s = document.createElement('style'); @@ -111,125 +195,134 @@ return view.extend({ // Not installed view if (!status.installed) { - var content = E('div', { 'class': 'ly-container' }, [ + return E('div', { 'class': 'ly-container' }, [ E('div', { 'class': 'ly-header' }, [ - E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]), + E('h2', {}, ['đŸŽĩ ', 'Lyrion Music Server']), E('div', { 'class': 'ly-status stopped' }, [ E('span', { 'class': 'ly-dot' }), - E('span', { 'class': 'ly-status-text' }, _('Not Installed')) + E('span', { 'class': 'ly-status-text' }, 'Not Installed') ]) ]), E('div', { 'class': 'ly-card' }, [ E('div', { 'class': 'ly-not-installed' }, [ - E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\ud83c\udfb5'), - E('h3', {}, _('Lyrion Music Server')), - E('p', {}, _('Self-hosted music streaming with Squeezebox/Logitech Media Server compatibility. Stream your music library to any device.')), - E('div', { 'class': 'ly-info-grid', 'style': 'margin-bottom:1.5rem;text-align:left' }, [ - E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Runtime')), - E('div', { 'class': 'ly-info-value' }, status.detected_runtime === 'lxc' ? 'LXC Container' : status.detected_runtime === 'docker' ? 'Docker' : _('None detected')) - ]), - E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Data Path')), - E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion') - ]), - E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Media Path')), - E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media') - ]) - ]), + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, 'đŸŽĩ'), + E('h3', {}, 'Lyrion Music Server'), + E('p', {}, 'Self-hosted music streaming with Squeezebox compatibility.'), E('button', { 'class': 'ly-btn ly-btn-primary', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': status.detected_runtime === 'none' - }, _('Install Lyrion')) + }, 'Install Lyrion') ]) ]) ]); - return KissTheme.wrap(content, 'admin/secubox/services/lyrion'); } // Installed view this.startPolling(); - var content = E('div', { 'class': 'ly-container' }, [ + return E('div', { 'class': 'ly-container' }, [ E('div', { 'class': 'ly-header' }, [ - E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]), + E('h2', {}, ['đŸŽĩ ', 'Lyrion Music Server']), E('div', { 'class': 'ly-status ' + (status.running ? 'running' : 'stopped') }, [ E('span', { 'class': 'ly-dot' }), - E('span', { 'class': 'ly-status-text' }, status.running ? _('Running') : _('Stopped')) + E('span', { 'class': 'ly-status-text' }, status.running ? 'Running' : 'Stopped') ]) ]), + // Stats Grid + E('div', { 'class': 'ly-stats-grid' }, [ + E('div', { 'class': 'ly-stat-card' }, [ + E('div', { 'class': 'ly-stat-value ly-stat-songs' }, this.formatNumber(stats.songs || 0)), + E('div', { 'class': 'ly-stat-label' }, 'Songs') + ]), + E('div', { 'class': 'ly-stat-card' }, [ + E('div', { 'class': 'ly-stat-value ly-stat-albums' }, this.formatNumber(stats.albums || 0)), + E('div', { 'class': 'ly-stat-label' }, 'Albums') + ]), + E('div', { 'class': 'ly-stat-card' }, [ + E('div', { 'class': 'ly-stat-value ly-stat-artists' }, this.formatNumber(stats.artists || 0)), + E('div', { 'class': 'ly-stat-label' }, 'Artists') + ]), + E('div', { 'class': 'ly-stat-card' }, [ + E('div', { 'class': 'ly-stat-value ly-stat-genres' }, this.formatNumber(stats.genres || 0)), + E('div', { 'class': 'ly-stat-label' }, 'Genres') + ]) + ]), + + // Scan Progress Bar + E('div', { 'class': 'ly-scan-bar' }, + stats.scanning ? + this.renderScanProgress(stats.scan_phase, + stats.scan_total > 0 ? Math.round((stats.scan_progress / stats.scan_total) * 100) : 0, + stats.scan_progress, stats.scan_total) : + '
✓ Library ReadyDB: ' + (stats.db_size || '0') + '
' + ), + // Info Card E('div', { 'class': 'ly-card' }, [ - E('div', { 'class': 'ly-card-title' }, ['\u2139\ufe0f ', _('Service Information')]), + E('div', { 'class': 'ly-card-title' }, ['â„šī¸ ', 'Service Information']), E('div', { 'class': 'ly-info-grid' }, [ E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Runtime')), - E('div', { 'class': 'ly-info-value ly-val-runtime' }, status.detected_runtime || 'auto') + E('div', { 'class': 'ly-info-label' }, 'Runtime'), + E('div', { 'class': 'ly-info-value' }, status.detected_runtime || 'auto') ]), E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Port')), - E('div', { 'class': 'ly-info-value ly-val-port' }, status.port || '9000') + E('div', { 'class': 'ly-info-label' }, 'Port'), + E('div', { 'class': 'ly-info-value' }, status.port || '9000') ]), E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Memory Limit')), - E('div', { 'class': 'ly-info-value ly-val-memory' }, status.memory_limit || '256M') + E('div', { 'class': 'ly-info-label' }, 'Memory'), + E('div', { 'class': 'ly-info-value' }, status.memory_limit || '256M') ]), E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Data Path')), - E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion') - ]), - E('div', { 'class': 'ly-info-item' }, [ - E('div', { 'class': 'ly-info-label' }, _('Media Path')), + E('div', { 'class': 'ly-info-label' }, 'Media Path'), E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media') ]) ]), // Web UI Link status.running && status.web_accessible ? E('div', { 'class': 'ly-webui' }, [ - E('div', { 'class': 'ly-webui-icon' }, '\ud83c\udf10'), + E('div', { 'class': 'ly-webui-icon' }, '🌐'), E('div', { 'class': 'ly-webui-info' }, [ - E('div', { 'style': 'font-weight:600' }, _('Web Interface')), + E('div', { 'style': 'font-weight:600' }, 'Web Interface'), E('div', { 'class': 'ly-webui-url' }, status.web_url) ]), E('a', { 'href': status.web_url, 'target': '_blank', 'class': 'ly-btn ly-btn-primary' - }, _('Open')) + }, 'Open') ]) : '' ]), // Actions Card E('div', { 'class': 'ly-card' }, [ - E('div', { 'class': 'ly-card-title' }, ['\u26a1 ', _('Actions')]), + E('div', { 'class': 'ly-card-title' }, ['⚡ ', 'Actions']), E('div', { 'class': 'ly-actions' }, [ E('button', { 'class': 'ly-btn ly-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running - }, _('Start')), + }, 'Start'), E('button', { 'class': 'ly-btn ly-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running - }, _('Stop')), + }, 'Stop'), E('button', { 'class': 'ly-btn ly-btn-secondary', 'click': ui.createHandlerFn(this, 'handleRestart'), 'disabled': !status.running - }, _('Restart')), - E('a', { - 'href': L.url('admin', 'secubox', 'services', 'lyrion', 'settings'), - 'class': 'ly-btn ly-btn-secondary' - }, _('Settings')) + }, 'Restart'), + E('button', { + 'class': 'ly-btn ly-btn-secondary', + 'click': ui.createHandlerFn(this, 'handleRescan'), + 'disabled': !status.running + }, 'Rescan Library') ]) ]) ]); - - return KissTheme.wrap(content, 'admin/secubox/services/lyrion'); }, handleSaveApply: null, diff --git a/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion b/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion index bc232108..874bfc27 100755 --- a/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion +++ b/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion @@ -193,6 +193,67 @@ get_logs() { echo "{\"logs\": \"$log_content\"}" } +# Get library stats from Lyrion API +get_library_stats() { + local port=$(uci_get port) + port=${port:-9000} + + # Query Lyrion JSON-RPC API for server status + local response=$(curl -s --max-time 3 "http://127.0.0.1:${port}/jsonrpc.js" \ + -H "Content-Type: application/json" \ + -d '{"id":1,"method":"slim.request","params":["", ["serverstatus", 0, 0]]}' 2>/dev/null) + + if [ -z "$response" ]; then + echo '{"songs":0,"albums":0,"artists":0,"genres":0,"scanning":false,"scan_progress":0,"scan_total":0,"scan_phase":""}' + return + fi + + # Parse response using jsonfilter + local songs=$(echo "$response" | jsonfilter -e '@.result["info total songs"]' 2>/dev/null || echo 0) + local albums=$(echo "$response" | jsonfilter -e '@.result["info total albums"]' 2>/dev/null || echo 0) + local artists=$(echo "$response" | jsonfilter -e '@.result["info total artists"]' 2>/dev/null || echo 0) + local genres=$(echo "$response" | jsonfilter -e '@.result["info total genres"]' 2>/dev/null || echo 0) + local rescan=$(echo "$response" | jsonfilter -e '@.result.rescan' 2>/dev/null || echo 0) + local progress_done=$(echo "$response" | jsonfilter -e '@.result.progressdone' 2>/dev/null || echo 0) + local progress_total=$(echo "$response" | jsonfilter -e '@.result.progresstotal' 2>/dev/null || echo 0) + local progress_name=$(echo "$response" | jsonfilter -e '@.result.progressname' 2>/dev/null || echo "") + + # Get database size + local db_size="0" + if [ -f /srv/lyrion/cache/library.db ]; then + db_size=$(ls -lh /srv/lyrion/cache/library.db 2>/dev/null | awk '{print $5}') + fi + + local scanning="false" + [ "$rescan" = "1" ] && scanning="true" + + cat </dev/null 2>&1 + + echo '{"success": true, "message": "Rescan started"}' +} + # RPCD list method list_methods() { cat <<'EOF' @@ -205,7 +266,9 @@ list_methods() { "stop": {}, "restart": {}, "update": {}, - "logs": {} + "logs": {}, + "get_library_stats": {}, + "rescan": {} } EOF } @@ -217,16 +280,18 @@ case "$1" in ;; call) case "$2" in - status) get_status ;; - get_config) get_config ;; - save_config) save_config ;; - install) do_install ;; - start) do_start ;; - stop) do_stop ;; - restart) do_restart ;; - update) do_update ;; - logs) get_logs ;; - *) echo '{"error": "Unknown method"}' ;; + status) get_status ;; + get_config) get_config ;; + save_config) save_config ;; + install) do_install ;; + start) do_start ;; + stop) do_stop ;; + restart) do_restart ;; + update) do_update ;; + logs) get_logs ;; + get_library_stats) get_library_stats ;; + rescan) do_rescan ;; + *) echo '{"error": "Unknown method"}' ;; esac ;; *) diff --git a/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json b/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json index af73e077..5fe3751a 100644 --- a/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json +++ b/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json @@ -3,13 +3,13 @@ "description": "Grant access to Lyrion Music Server", "read": { "ubus": { - "luci.lyrion": ["status", "get_config", "logs"] + "luci.lyrion": ["status", "get_config", "logs", "get_library_stats"] }, "uci": ["lyrion"] }, "write": { "ubus": { - "luci.lyrion": ["install", "start", "stop", "restart", "update", "save_config"] + "luci.lyrion": ["install", "start", "stop", "restart", "update", "save_config", "rescan"] }, "uci": ["lyrion"] } diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js index 0d483e59..cc6a155b 100644 --- a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js @@ -222,6 +222,13 @@ return view.extend({ 'title': exp.auth_required ? _('Authentication required - click to disable') : _('No authentication - click to enable'), 'click': ui.createHandlerFn(self, 'handleToggleAuth', site, exp) }, exp.auth_required ? _('Unlock') : _('Lock')), + // Health/Repair button + E('button', { + 'class': 'cbi-button', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Check health and repair'), + 'click': ui.createHandlerFn(self, 'handleHealthCheck', site) + }, _('Health')), // Delete button E('button', { 'class': 'cbi-button cbi-button-remove', @@ -547,6 +554,141 @@ return view.extend({ ]); }, + handleHealthCheck: function(site) { + var self = this; + + ui.showModal(_('Health Check'), [ + E('p', { 'class': 'spinning' }, _('Checking site health...')) + ]); + + api.checkSiteHealth(site.id).then(function(health) { + var statusItems = []; + var hasIssues = false; + + // Backend status + if (health.backend_status === 'ok') { + statusItems.push(E('div', { 'style': 'color:#155724; margin:0.5em 0' }, + '✓ Backend: Running (port ' + (health.port || site.port || '?') + ')')); + } else { + hasIssues = true; + statusItems.push(E('div', { 'style': 'color:#721c24; margin:0.5em 0' }, + '✗ Backend: ' + (health.backend_status || 'Not responding'))); + } + + // Frontend status + if (health.frontend_status === 'ok') { + statusItems.push(E('div', { 'style': 'color:#155724; margin:0.5em 0' }, + '✓ Frontend: Accessible via HAProxy')); + } else if (health.frontend_status === 'not_configured') { + statusItems.push(E('div', { 'style': 'color:#856404; margin:0.5em 0' }, + '○ Frontend: Not exposed (private)')); + } else { + hasIssues = true; + statusItems.push(E('div', { 'style': 'color:#721c24; margin:0.5em 0' }, + '✗ Frontend: ' + (health.frontend_status || 'Error'))); + } + + // SSL status + if (health.ssl_status === 'valid') { + statusItems.push(E('div', { 'style': 'color:#155724; margin:0.5em 0' }, + '✓ SSL: Valid' + (health.ssl_days_remaining ? ' (' + health.ssl_days_remaining + ' days)' : ''))); + } else if (health.ssl_status === 'expiring') { + statusItems.push(E('div', { 'style': 'color:#856404; margin:0.5em 0' }, + '! SSL: Expiring soon (' + (health.ssl_days_remaining || '?') + ' days)')); + } else if (health.ssl_status === 'not_configured') { + statusItems.push(E('div', { 'style': 'color:#888; margin:0.5em 0' }, + '○ SSL: Not configured')); + } else if (health.ssl_status) { + hasIssues = true; + statusItems.push(E('div', { 'style': 'color:#721c24; margin:0.5em 0' }, + '✗ SSL: ' + health.ssl_status)); + } + + // Content check + if (health.has_content) { + statusItems.push(E('div', { 'style': 'color:#155724; margin:0.5em 0' }, + '✓ Content: index.html exists')); + } else { + hasIssues = true; + statusItems.push(E('div', { 'style': 'color:#721c24; margin:0.5em 0' }, + '✗ Content: No index.html')); + } + + ui.hideModal(); + + var modalContent = [ + E('h4', { 'style': 'margin-top:0' }, site.name + ' (' + (site.domain || 'no domain') + ')'), + E('div', { 'style': 'padding:1em; background:#f8f8f8; border-radius:4px' }, statusItems), + E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function() { + ui.hideModal(); + self.handleRepair(site); + } + }, _('Repair')) + ]) + ]; + + if (hasIssues) { + modalContent.splice(1, 0, E('p', { 'style': 'color:#721c24; font-weight:bold' }, + _('Issues detected - click Repair to fix'))); + } else { + modalContent.splice(1, 0, E('p', { 'style': 'color:#155724' }, + _('All checks passed'))); + } + + ui.showModal(_('Health Check Results'), modalContent); + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Health check failed: ') + e.message), 'error'); + }); + }, + + handleRepair: function(site) { + var self = this; + + ui.showModal(_('Repairing Site'), [ + E('p', { 'class': 'spinning' }, _('Fixing permissions and restarting services...')) + ]); + + api.repairSite(site.id).then(function(result) { + ui.hideModal(); + + if (result.success) { + var repairList = (result.repairs || '').split(' ').filter(function(r) { return r; }); + var repairMsg = repairList.length > 0 ? + _('Repairs performed: ') + repairList.join(', ') : + _('No repairs needed'); + + ui.showModal(_('Repair Complete'), [ + E('p', { 'style': 'color:#155724' }, '✓ ' + repairMsg), + E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + ui.hideModal(); + self.handleHealthCheck(site); + } + }, _('Re-check')), + ' ', + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { + ui.hideModal(); + window.location.reload(); + }}, _('Done')) + ]) + ]); + } else { + ui.addNotification(null, E('p', _('Repair failed: ') + (result.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Repair error: ') + e.message), 'error'); + }); + }, + showEditModal: function(site) { var self = this; diff --git a/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js b/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js index 876f51ec..2eae8a84 100644 --- a/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js +++ b/package/secubox/luci-app-photoprism/htdocs/luci-static/resources/view/photoprism/overview.js @@ -16,6 +16,12 @@ var callGetStats = rpc.declare({ expect: {} }); +var callGetIndexProgress = rpc.declare({ + object: 'luci.photoprism', + method: 'get_index_progress', + expect: {} +}); + var callStart = rpc.declare({ object: 'luci.photoprism', method: 'start', @@ -113,6 +119,76 @@ return view.extend({ .kiss-badge-success { background: var(--kiss-success); color: #000; } .kiss-badge-danger { background: var(--kiss-accent); color: #fff; } .kiss-badge-warning { background: var(--kiss-warning); color: #000; } + + .kiss-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 15px; + margin-bottom: 25px; + } + @media (max-width: 768px) { .kiss-stats-grid { grid-template-columns: repeat(2, 1fr); } } + .kiss-stat-card { + background: var(--kiss-card); + border: 1px solid var(--kiss-border); + border-radius: 12px; + padding: 20px; + text-align: center; + } + .kiss-stat-value { + font-size: 2em; + font-weight: 700; + color: var(--kiss-accent); + } + .kiss-stat-label { + font-size: 0.85rem; + color: var(--kiss-muted); + margin-top: 5px; + } + + .kiss-index-bar { + background: var(--kiss-card); + border: 1px solid var(--kiss-border); + border-radius: 12px; + padding: 15px 20px; + margin-bottom: 25px; + } + .kiss-index-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + .kiss-index-title { + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + .kiss-index-file { + font-size: 0.8rem; + color: var(--kiss-muted); + font-family: monospace; + } + .kiss-progress-track { + height: 8px; + background: #333; + border-radius: 4px; + overflow: hidden; + } + .kiss-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--kiss-accent), #8b5cf6); + border-radius: 4px; + transition: width 0.5s; + } + .kiss-index-idle { + color: var(--kiss-success); + } + .kiss-pulse { + animation: pulse 1.5s infinite; + } + @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} } + .kiss-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -239,12 +315,14 @@ return view.extend({ status: null, stats: null, config: null, + indexProgress: null, load: function() { return Promise.all([ callStatus(), callGetStats(), - callGetConfig() + callGetConfig(), + callGetIndexProgress() ]); }, @@ -253,6 +331,7 @@ return view.extend({ this.status = data[0] || {}; this.stats = data[1] || {}; this.config = data[2] || {}; + this.indexProgress = data[3] || {}; var container = E('div', { 'class': 'kiss-container' }, [ E('style', {}, this.css), @@ -261,13 +340,14 @@ return view.extend({ ]); poll.add(function() { - return Promise.all([callStatus(), callGetStats(), callGetConfig()]).then(function(results) { + return Promise.all([callStatus(), callGetStats(), callGetConfig(), callGetIndexProgress()]).then(function(results) { self.status = results[0] || {}; self.stats = results[1] || {}; self.config = results[2] || {}; + self.indexProgress = results[3] || {}; self.updateView(); }); - }, 10); + }, 5); return container; }, @@ -310,22 +390,51 @@ return view.extend({ ]); }, + formatNumber: function(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return n.toString(); + }, + renderDashboard: function() { var self = this; var status = this.status; var stats = this.stats; + var idx = this.indexProgress; return E('div', {}, [ - // Stats Grid - E('div', { 'class': 'kiss-grid', 'id': 'stats-grid' }, [ - E('div', { 'class': 'kiss-card' }, [ - E('h4', {}, 'Photos'), - E('div', { 'class': 'value accent', 'data-stat': 'photos' }, stats.photo_count || '0') + // Live Stats Grid + E('div', { 'class': 'kiss-stats-grid', 'id': 'stats-grid' }, [ + E('div', { 'class': 'kiss-stat-card' }, [ + E('div', { 'class': 'kiss-stat-value', 'data-stat': 'sidecars' }, this.formatNumber(idx.sidecar_count || 0)), + E('div', { 'class': 'kiss-stat-label' }, 'Indexed') ]), - E('div', { 'class': 'kiss-card' }, [ - E('h4', {}, 'Videos'), - E('div', { 'class': 'value', 'data-stat': 'videos' }, stats.video_count || '0') + E('div', { 'class': 'kiss-stat-card' }, [ + E('div', { 'class': 'kiss-stat-value', 'data-stat': 'thumbnails' }, this.formatNumber(idx.thumbnail_count || 0)), + E('div', { 'class': 'kiss-stat-label' }, 'Thumbnails') ]), + E('div', { 'class': 'kiss-stat-card' }, [ + E('div', { 'class': 'kiss-stat-value', 'data-stat': 'photos' }, this.formatNumber(stats.photo_count || 0)), + E('div', { 'class': 'kiss-stat-label' }, 'Photos') + ]), + E('div', { 'class': 'kiss-stat-card' }, [ + E('div', { 'class': 'kiss-stat-value', 'data-stat': 'videos' }, this.formatNumber(stats.video_count || 0)), + E('div', { 'class': 'kiss-stat-label' }, 'Videos') + ]) + ]), + + // Index Progress Bar + E('div', { 'class': 'kiss-index-bar', 'id': 'index-bar' }, + idx.indexing ? + this.renderIndexProgress(idx) : + E('div', { 'class': 'kiss-index-header' }, [ + E('span', { 'class': 'kiss-index-title kiss-index-idle' }, ['✓ ', 'Ready']), + E('span', { 'class': 'kiss-index-file' }, 'DB: ' + (idx.db_size || '0')) + ]) + ), + + // Storage Stats + E('div', { 'class': 'kiss-grid' }, [ E('div', { 'class': 'kiss-card' }, [ E('h4', {}, 'Originals Size'), E('div', { 'class': 'value', 'data-stat': 'originals' }, stats.originals_size || '0') @@ -358,8 +467,7 @@ return view.extend({ this.disabled = true; this.textContent = 'Indexing...'; callIndex().then(function(res) { - ui.addNotification(null, E('p', {}, 'Indexing complete'), 'success'); - window.location.reload(); + ui.addNotification(null, E('p', {}, 'Indexing started'), 'success'); }); } }, 'Index Photos'), @@ -480,18 +588,50 @@ return view.extend({ ]); }, + renderIndexProgress: function(idx) { + return E('div', {}, [ + E('div', { 'class': 'kiss-index-header' }, [ + E('span', { 'class': 'kiss-index-title' }, [ + E('span', { 'class': 'kiss-pulse' }, 'âŗ'), + ' Indexing...' + ]), + E('span', { 'class': 'kiss-index-file' }, idx.current_file || 'Processing...') + ]), + E('div', { 'class': 'kiss-progress-track' }, [ + E('div', { 'class': 'kiss-progress-bar', 'style': 'width: 100%' }) + ]) + ]); + }, + updateView: function() { var stats = this.stats; + var idx = this.indexProgress; + // Update stat cards + var sidecarEl = document.querySelector('[data-stat="sidecars"]'); + var thumbEl = document.querySelector('[data-stat="thumbnails"]'); var photosEl = document.querySelector('[data-stat="photos"]'); var videosEl = document.querySelector('[data-stat="videos"]'); var originalsEl = document.querySelector('[data-stat="originals"]'); var cacheEl = document.querySelector('[data-stat="cache"]'); - if (photosEl) photosEl.textContent = stats.photo_count || '0'; - if (videosEl) videosEl.textContent = stats.video_count || '0'; + if (sidecarEl) sidecarEl.textContent = this.formatNumber(idx.sidecar_count || 0); + if (thumbEl) thumbEl.textContent = this.formatNumber(idx.thumbnail_count || 0); + if (photosEl) photosEl.textContent = this.formatNumber(stats.photo_count || 0); + if (videosEl) videosEl.textContent = this.formatNumber(stats.video_count || 0); if (originalsEl) originalsEl.textContent = stats.originals_size || '0'; if (cacheEl) cacheEl.textContent = stats.storage_size || '0'; + + // Update index bar + var indexBar = document.getElementById('index-bar'); + if (indexBar) { + if (idx.indexing) { + indexBar.innerHTML = ''; + indexBar.appendChild(this.renderIndexProgress(idx)); + } else { + indexBar.innerHTML = '
✓ ReadyDB: ' + (idx.db_size || '0') + '
'; + } + } }, handleSaveApply: null, diff --git a/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism b/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism index ba74fd7b..fed13f93 100644 --- a/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism +++ b/package/secubox/luci-app-photoprism/root/usr/libexec/rpcd/luci.photoprism @@ -70,6 +70,58 @@ method_get_stats() { json_dump } +# Method: get_index_progress - Live indexing stats +method_get_index_progress() { + local data_path=$(uci -q get ${CONFIG}.main.data_path || echo '/srv/photoprism') + local storage_path="${data_path}/storage" + + # Count indexed files + local sidecar_count=0 + local thumbnail_count=0 + local db_size="0" + local indexing=false + local current_file="" + local last_activity="" + + if [ -d "${storage_path}/sidecar" ]; then + sidecar_count=$(find "${storage_path}/sidecar" -type f 2>/dev/null | wc -l || echo 0) + fi + + if [ -d "${storage_path}/cache/thumbnails" ]; then + thumbnail_count=$(find "${storage_path}/cache/thumbnails" -type f 2>/dev/null | wc -l || echo 0) + fi + + if [ -f "${storage_path}/photoprism.db" ]; then + db_size=$(ls -lh "${storage_path}/photoprism.db" 2>/dev/null | awk '{print $5}') + fi + + # Check if indexing process is running + if lxc-attach -n photoprism -- ps aux 2>/dev/null | grep -q "photoprism index"; then + indexing=true + fi + + # Get last log entry + local log_file="${storage_path}/index.log" + if [ -f "$log_file" ]; then + local last_line=$(tail -1 "$log_file" 2>/dev/null) + # Extract filename from log + current_file=$(echo "$last_line" | grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\.[a-z]+' | head -1) + # Extract timestamp + last_activity=$(echo "$last_line" | grep -oE 'time="[^"]+"' | cut -d'"' -f2) + fi + + cat </dev/null 2>&1 @@ -180,6 +232,7 @@ case "$1" in "get_config": {}, "set_config": {"originals_path": "string"}, "get_stats": {}, + "get_index_progress": {}, "start": {}, "stop": {}, "restart": {}, @@ -201,6 +254,7 @@ case "$1" in method_set_config "$originals_path" ;; get_stats) method_get_stats ;; + get_index_progress) method_get_index_progress ;; start) method_start ;; stop) method_stop ;; restart) method_restart ;; diff --git a/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json b/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json index 339d8c21..ca08c405 100644 --- a/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json +++ b/package/secubox/luci-app-photoprism/root/usr/share/rpcd/acl.d/luci-photoprism.json @@ -7,6 +7,7 @@ "status", "get_config", "get_stats", + "get_index_progress", "logs" ] }, diff --git a/package/secubox/secubox-app-lyrion/README.md b/package/secubox/secubox-app-lyrion/README.md index 1cd6ddaa..6a1c0153 100644 --- a/package/secubox/secubox-app-lyrion/README.md +++ b/package/secubox/secubox-app-lyrion/README.md @@ -2,10 +2,21 @@ Lyrion Music Server (formerly Logitech Media Server / Squeezebox Server) for SecuBox-powered OpenWrt systems. +## Overview + +Runs Lyrion in a Debian Bookworm LXC container with: +- Official Lyrion Debian package (v9.x) +- Full audio codec support (FLAC, MP3, AAC, etc.) +- Squeezebox player discovery (UDP 3483) +- Web interface on configurable port (default: 9000) + ## Installation ```sh opkg install secubox-app-lyrion +lyrionctl install # Creates Debian LXC container +/etc/init.d/lyrion enable +/etc/init.d/lyrion start ``` ## Configuration @@ -16,32 +27,67 @@ UCI config file: `/etc/config/lyrion` config lyrion 'main' option enabled '0' option port '9000' + option data_path '/srv/lyrion' + option media_path '/srv/media' + option memory_limit '1G' + option extra_media_paths '/mnt/usb:/mnt/usb' ``` -Supports Docker and LXC runtimes. The controller auto-detects the available runtime, preferring LXC for lower resource usage. +### Extra Media Paths + +Mount additional media directories (space-separated): +``` +option extra_media_paths '/mnt/MUSIC /mnt/USB:/music/usb' +``` ## Usage ```sh -# Start / stop the service +# Service management /etc/init.d/lyrion start /etc/init.d/lyrion stop +/etc/init.d/lyrion restart # Controller CLI -lyrionctl status -lyrionctl install -lyrionctl remove +lyrionctl status # Show container status +lyrionctl install # Create Debian LXC container +lyrionctl destroy # Remove container (preserves config) +lyrionctl update # Rebuild container with latest Lyrion +lyrionctl logs # View server logs +lyrionctl logs -f # Follow logs +lyrionctl shell # Open shell in container +lyrionctl runtime # Show detected runtime ``` +## Container Architecture + +The container uses Debian Bookworm with: +- Official Lyrion repository packages +- Bind mounts for config (`/srv/lyrion`) and media +- Shared host networking for player discovery +- Memory limits via cgroup2 + +## Ports + +| Port | Protocol | Description | +|------|----------|-------------| +| 9000 | TCP | Web interface | +| 9090 | TCP | CLI/RPC interface | +| 3483 | TCP | Slim Protocol (players) | +| 3483 | UDP | Player discovery | + ## Files - `/etc/config/lyrion` -- UCI configuration -- `/usr/sbin/lyrionctl` -- controller CLI +- `/usr/sbin/lyrionctl` -- Controller CLI +- `/srv/lyrion/` -- Persistent config and cache +- `/srv/lxc/lyrion/` -- LXC container rootfs ## Dependencies -- `wget` -- `tar` +- `lxc` (or `docker`) +- `debootstrap` (auto-installed for LXC) +- `wget`, `tar` ## License diff --git a/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl b/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl index f4f67486..1192d284 100755 --- a/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl +++ b/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl @@ -350,19 +350,25 @@ lxc_check_prereqs() { lxc_create_rootfs() { load_config - # Check for COMPLETE installation (Lyrion installed, not just Alpine) - if [ -d "$LXC_ROOTFS" ] && [ -f "$LXC_ROOTFS/opt/lyrion/slimserver.pl" ] && [ -f "$LXC_CONFIG" ]; then + # Check for COMPLETE installation (Lyrion installed via Debian package) + if [ -d "$LXC_ROOTFS" ] && [ -x "$LXC_ROOTFS/usr/sbin/squeezeboxserver" ] && [ -f "$LXC_CONFIG" ]; then log_info "LXC rootfs already exists with Lyrion installed" return 0 fi - # Check for incomplete installation (Alpine exists but Lyrion not installed) - if [ -d "$LXC_ROOTFS" ] && [ -f "$LXC_ROOTFS/etc/alpine-release" ] && [ ! -f "$LXC_ROOTFS/opt/lyrion/slimserver.pl" ]; then - log_warn "Incomplete installation detected (Alpine downloaded but Lyrion not installed)" + # Check for incomplete installation (Debian exists but Lyrion not installed) + if [ -d "$LXC_ROOTFS" ] && [ -f "$LXC_ROOTFS/etc/debian_version" ] && [ ! -x "$LXC_ROOTFS/usr/sbin/squeezeboxserver" ]; then + log_warn "Incomplete installation detected (Debian downloaded but Lyrion not installed)" log_info "Cleaning up incomplete rootfs..." rm -rf "$LXC_PATH/$LXC_NAME" fi + # Clean up old Alpine-based installation if present + if [ -d "$LXC_ROOTFS" ] && [ -f "$LXC_ROOTFS/etc/alpine-release" ]; then + log_warn "Old Alpine-based installation detected, replacing with Debian..." + rm -rf "$LXC_PATH/$LXC_NAME" + fi + log_info "Creating LXC rootfs for Lyrion..." ensure_dir "$LXC_PATH/$LXC_NAME" @@ -370,13 +376,13 @@ lxc_create_rootfs() { if [ -x "$LYRION_ROOTFS_SCRIPT" ]; then "$LYRION_ROOTFS_SCRIPT" "$LXC_ROOTFS" || return 1 else - # Inline rootfs creation - lxc_create_alpine_rootfs || return 1 + # Inline rootfs creation (Debian-based) + lxc_create_debian_rootfs || return 1 fi # Verify Lyrion was actually installed - if [ ! -f "$LXC_ROOTFS/opt/lyrion/slimserver.pl" ]; then - log_error "Lyrion installation failed - slimserver.pl not found" + if [ ! -x "$LXC_ROOTFS/usr/sbin/squeezeboxserver" ]; then + log_error "Lyrion installation failed - squeezeboxserver not found" log_error "Check network connectivity and try again" return 1 fi @@ -387,175 +393,113 @@ lxc_create_rootfs() { log_info "LXC rootfs created successfully" } -lxc_create_alpine_rootfs() { - local arch="aarch64" - local alpine_version="3.19" - local mirror="https://dl-cdn.alpinelinux.org/alpine" +lxc_create_debian_rootfs() { + local arch="arm64" + local debian_version="bookworm" local rootfs="$LXC_ROOTFS" # Detect architecture case "$(uname -m)" in - x86_64) arch="x86_64" ;; - aarch64) arch="aarch64" ;; - armv7l) arch="armv7" ;; - *) arch="x86_64" ;; + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + armv7l) arch="armhf" ;; + *) arch="amd64" ;; esac - log_info "Downloading Alpine Linux $alpine_version ($arch)..." + log_info "Creating Debian $debian_version ($arch) rootfs..." ensure_dir "$rootfs" - cd "$rootfs" || return 1 - # Download Alpine minirootfs - local rootfs_url="$mirror/v$alpine_version/releases/$arch/alpine-minirootfs-$alpine_version.0-$arch.tar.gz" - wget -q -O /tmp/alpine-rootfs.tar.gz "$rootfs_url" || { - log_error "Failed to download Alpine rootfs" + # Check if debootstrap is available + if ! command -v debootstrap >/dev/null 2>&1; then + log_info "Installing debootstrap..." + ensure_packages debootstrap || return 1 + fi + + # Create minimal Debian rootfs + log_info "Running debootstrap (this may take several minutes)..." + debootstrap --arch="$arch" --variant=minbase "$debian_version" "$rootfs" http://deb.debian.org/debian || { + log_error "Failed to create Debian rootfs" return 1 } - # Extract rootfs - tar xzf /tmp/alpine-rootfs.tar.gz -C "$rootfs" || return 1 - rm -f /tmp/alpine-rootfs.tar.gz - - # Configure Alpine + # Configure DNS echo "nameserver 8.8.8.8" > "$rootfs/etc/resolv.conf" # Install Lyrion in the container cat > "$rootfs/tmp/setup-lyrion.sh" << 'SETUP' -#!/bin/sh +#!/bin/bash set -e +export DEBIAN_FRONTEND=noninteractive +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 -# Update and install dependencies -apk update -apk add --no-cache \ - perl \ - perl-io-socket-ssl \ - perl-encode \ - perl-xml-parser \ - perl-xml-simple \ - perl-dbi \ - perl-dbd-sqlite \ - perl-json-xs \ - perl-yaml-libyaml \ - perl-crypt-openssl-rsa \ - perl-ev \ - perl-anyevent \ - perl-gd \ - perl-digest-sha1 \ - perl-sub-name \ - perl-html-parser \ - perl-template-toolkit \ - perl-file-slurp \ - imagemagick \ +echo "Updating package lists..." +apt-get update + +echo "Installing Lyrion Music Server..." +# Add Lyrion repository +apt-get install -y gnupg curl ca-certificates locales + +# Generate locale +echo "en_US.UTF-8 UTF-8" > /etc/locale.gen +locale-gen + +# Add Lyrion repository key and source +curl -fsSL https://downloads.lms-community.org/LyrionMusicServer.gpg -o /etc/apt/keyrings/lyrionmusicserver.gpg +echo "deb [signed-by=/etc/apt/keyrings/lyrionmusicserver.gpg] https://downloads.lms-community.org/repo/apt stable main" > /etc/apt/sources.list.d/lyrionmusicserver.list + +apt-get update +apt-get install -y lyrionmusicserver + +# Install additional audio codecs +apt-get install -y --no-install-recommends \ flac \ - faad2 \ - sox \ lame \ - curl \ - wget + sox \ + faad \ + libio-socket-ssl-perl -# Download and install Lyrion -cd /tmp - -# Detect architecture for appropriate tarball -LYRION_ARCH="" -case "$(uname -m)" in - aarch64|arm*) LYRION_ARCH="arm-linux" ;; -esac - -# Try ARM-specific tarball first (smaller ~60MB), then fall back to multi-arch (~126MB) -echo "Downloading Lyrion Music Server..." -LYRION_URL="" -if [ -n "$LYRION_ARCH" ]; then - LYRION_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver-9.0.3-${LYRION_ARCH}.tgz" -else - LYRION_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver-9.0.3.tgz" -fi - -echo "URL: $LYRION_URL" -wget -O lyrion.tar.gz "$LYRION_URL" || { - echo "Primary download failed, trying multi-arch tarball..." - LYRION_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver-9.0.3.tgz" - wget -O lyrion.tar.gz "$LYRION_URL" || { - echo "ERROR: All download attempts failed" - exit 1 - } -} - -# Verify download succeeded -if [ ! -f lyrion.tar.gz ] || [ ! -s lyrion.tar.gz ]; then - echo "ERROR: Failed to download Lyrion tarball" - exit 1 -fi - -mkdir -p /opt/lyrion -tar xzf lyrion.tar.gz -C /opt/lyrion --strip-components=1 || { - echo "ERROR: Failed to extract Lyrion tarball" - exit 1 -} -rm -f lyrion.tar.gz - -# Remove conflicting bundled CPAN modules (use system modules instead) -rm -rf /opt/lyrion/CPAN/arch -rm -rf /opt/lyrion/CPAN/XML/Parser* -rm -rf /opt/lyrion/CPAN/Image -rm -rf /opt/lyrion/CPAN/DBD -rm -rf /opt/lyrion/CPAN/DBI /opt/lyrion/CPAN/DBI.pm -rm -rf /opt/lyrion/CPAN/YAML -rm -rf /opt/lyrion/CPAN/Template /opt/lyrion/CPAN/Template.pm - -# Create stub Image::Scale module (artwork resizing - optional) -mkdir -p /opt/lyrion/CPAN/Image -cat > /opt/lyrion/CPAN/Image/Scale.pm << 'STUB' -package Image::Scale; -our $VERSION = '0.08'; -sub new { return bless {}, shift } -sub resize { return 1 } -sub width { return 0 } -sub height { return 0 } -1; -STUB - -# Create stub Devel::Peek module (runtime inspection - optional) -mkdir -p /opt/lyrion/CPAN/Devel -cat > /opt/lyrion/CPAN/Devel/Peek.pm << 'STUB' -package Devel::Peek; -use strict; -our $VERSION = '1.32'; -our $ANON_GV; -{ no strict 'refs'; $ANON_GV = \*{'Devel::Peek::ANON'}; } -sub Dump { } -sub DumpArray { } -sub SvREFCNT { return 1 } -sub DeadCode { } -sub mstat { } -sub fill_mstats { } -sub SvROK { return 0 } -sub CvGV { return $ANON_GV } -1; -STUB - -# Create directories with proper permissions for nobody user (uid 65534) +# Create directories with proper permissions mkdir -p /config/prefs/plugin /config/cache /music /var/log/lyrion -chown -R 65534:65534 /config /var/log/lyrion /opt/lyrion +chown -R nobody:nogroup /config /var/log/lyrion -# Create startup script (runs as nobody via LXC init.uid/gid) -cat > /opt/lyrion/start.sh << 'START' -#!/bin/sh -cd /opt/lyrion +# Create startup script +cat > /opt/init.sh << 'START' +#!/bin/bash +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 -# Ensure directories exist (ownership set during LXC config creation) -mkdir -p /config/prefs/plugin /config/cache /var/log/lyrion 2>/dev/null || true +# Ensure directories exist with proper permissions +mkdir -p /config/prefs /config/cache /var/log/lyrion /music +chown -R nobody:nogroup /config /var/log/lyrion +chmod -R 777 /config /var/log/lyrion -# Run Lyrion (already running as nobody via LXC init.uid/gid settings) -exec perl slimserver.pl \ +# Create default prefs if not exists +if [ ! -f /config/prefs/server.prefs ]; then + cat > /config/prefs/server.prefs << PREFS +--- +mediadirs: + - /music +httpport: 9000 +cliport: 9090 +PREFS + chown nobody:nogroup /config/prefs/server.prefs +fi + +# Run Lyrion (squeezeboxserver drops privileges to nobody when run as root) +exec /usr/sbin/squeezeboxserver \ --prefsdir /config/prefs \ --cachedir /config/cache \ --logdir /var/log/lyrion \ --httpport 9000 \ --cliport 9090 START -chmod +x /opt/lyrion/start.sh +chmod +x /opt/init.sh + +# Clean up +apt-get clean +rm -rf /var/lib/apt/lists/* echo "Lyrion installed successfully" SETUP @@ -564,7 +508,7 @@ SETUP # Run setup in chroot log_info "Installing Lyrion in container (this may take a while)..." - chroot "$rootfs" /tmp/setup-lyrion.sh || { + chroot "$rootfs" /bin/bash /tmp/setup-lyrion.sh || { log_error "Failed to install Lyrion in container" return 1 } @@ -635,12 +579,8 @@ lxc.cgroup2.memory.max = $mem_bytes lxc.seccomp.profile = lxc.autodev = 1 -# Run as nobody user (uid/gid 65534) - Lyrion must not run as root -lxc.init.uid = 65534 -lxc.init.gid = 65534 - -# Init -lxc.init.cmd = /opt/lyrion/start.sh +# Init - Debian-based with squeezeboxserver (drops privileges to nobody internally) +lxc.init.cmd = /opt/init.sh # Console lxc.console.size = 1024 diff --git a/package/secubox/secubox-avatar-tap/Makefile b/package/secubox/secubox-avatar-tap/Makefile new file mode 100644 index 00000000..ebb72844 --- /dev/null +++ b/package/secubox/secubox-avatar-tap/Makefile @@ -0,0 +1,36 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-avatar-tap +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-avatar-tap + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=Avatar Session Tap and Replayer + DEPENDS:=+python3 +python3-mitmproxy +python3-sqlite3 +python3-requests + PKGARCH:=all +endef + +define Package/secubox-avatar-tap/description + Passive network tap for capturing and replaying authenticated sessions. + Part of the SecuBox Avatar authentication relay system. + Designed to work with Nitrokey/GPG for secure session management. +endef + +define Package/secubox-avatar-tap/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/avatar-tap $(1)/etc/config/avatar-tap + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/avatar-tap $(1)/etc/init.d/avatar-tap + $(INSTALL_DIR) $(1)/usr/share/avatar-tap + $(INSTALL_DATA) ./files/usr/share/avatar-tap/tap.py $(1)/usr/share/avatar-tap/tap.py + $(INSTALL_DATA) ./files/usr/share/avatar-tap/replay.py $(1)/usr/share/avatar-tap/replay.py + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/avatar-tapctl $(1)/usr/sbin/avatar-tapctl + $(INSTALL_DIR) $(1)/srv/avatar-tap +endef + +$(eval $(call BuildPackage,secubox-avatar-tap)) diff --git a/package/secubox/secubox-avatar-tap/files/etc/config/avatar-tap b/package/secubox/secubox-avatar-tap/files/etc/config/avatar-tap new file mode 100644 index 00000000..4b42ede5 --- /dev/null +++ b/package/secubox/secubox-avatar-tap/files/etc/config/avatar-tap @@ -0,0 +1,19 @@ +config avatar-tap 'main' + option enabled '0' + option listen_port '8888' + option listen_addr '0.0.0.0' + option mode 'transparent' + option db_path '/srv/avatar-tap/sessions.db' + option log_path '/var/log/avatar-tap.log' + +config avatar-tap 'capture' + option capture_cookies '1' + option capture_auth_headers '1' + option capture_tokens '1' + list domains_filter '' + +config avatar-tap 'security' + option require_nitrokey '0' + option gpg_keyid '' + option session_ttl '86400' + option auto_cleanup '1' diff --git a/package/secubox/secubox-avatar-tap/files/etc/init.d/avatar-tap b/package/secubox/secubox-avatar-tap/files/etc/init.d/avatar-tap new file mode 100755 index 00000000..f4a68cee --- /dev/null +++ b/package/secubox/secubox-avatar-tap/files/etc/init.d/avatar-tap @@ -0,0 +1,45 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG="/usr/bin/mitmdump" +TAP_SCRIPT="/usr/share/avatar-tap/tap.py" + +start_service() { + local enabled + config_load avatar-tap + config_get enabled main enabled '0' + + [ "$enabled" = "1" ] || return 0 + + local listen_port listen_addr mode db_path log_path + config_get listen_port main listen_port '8888' + config_get listen_addr main listen_addr '0.0.0.0' + config_get mode main mode 'transparent' + config_get db_path main db_path '/srv/avatar-tap/sessions.db' + config_get log_path main log_path '/var/log/avatar-tap.log' + + # Ensure directories exist + mkdir -p "$(dirname "$db_path")" + mkdir -p "$(dirname "$log_path")" + + procd_open_instance + procd_set_param command $PROG \ + -s "$TAP_SCRIPT" \ + -p "$listen_port" \ + --listen-host "$listen_addr" \ + --mode "$mode" \ + --set "db_path=$db_path" + procd_set_param env AVATAR_TAP_DB="$db_path" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param file /etc/config/avatar-tap + procd_set_param respawn + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "avatar-tap" +} diff --git a/package/secubox/secubox-avatar-tap/files/usr/sbin/avatar-tapctl b/package/secubox/secubox-avatar-tap/files/usr/sbin/avatar-tapctl new file mode 100755 index 00000000..45f6116d --- /dev/null +++ b/package/secubox/secubox-avatar-tap/files/usr/sbin/avatar-tapctl @@ -0,0 +1,262 @@ +#!/bin/sh +# SecuBox Avatar Tap Control Script + +. /lib/functions.sh + +PROG_NAME="avatar-tapctl" +CONFIG_FILE="/etc/config/avatar-tap" +DB_PATH="/srv/avatar-tap/sessions.db" +TAP_SCRIPT="/usr/share/avatar-tap/tap.py" +REPLAY_SCRIPT="/usr/share/avatar-tap/replay.py" +PID_FILE="/var/run/avatar-tap.pid" + +# Load config +config_load avatar-tap + +usage() { + cat < [options] + +Commands: + start Start the passive tap + stop Stop the tap + restart Restart the tap + status Show tap status + list [domain] List captured sessions + show Show session details + replay [method] Replay session to URL + label