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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-06 20:41:21 +01:00
parent 461535e468
commit d01828d632
21 changed files with 2045 additions and 280 deletions

View File

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

View File

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

View File

@ -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 <id> <url>')
])
])
]);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

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

View File

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

View File

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

View File

@ -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 = '<div class="ly-scan-header"><span class="ly-scan-title ly-scan-idle">Library Ready</span><span class="ly-scan-phase">DB: ' + (stats.db_size || '0') + '</span></div>';
}
}
},
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 '<div class="ly-scan-header">' +
'<span class="ly-scan-title"><span style="animation:pulse 1s infinite">⏳</span> Scanning...</span>' +
'<span class="ly-scan-phase">' + (phase || 'Processing') + ' (' + done + '/' + total + ')</span>' +
'</div>' +
'<div class="ly-progress-track"><div class="ly-progress-bar" style="width:' + pct + '%"></div></div>';
},
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) :
'<div class="ly-scan-header"><span class="ly-scan-title ly-scan-idle">✓ Library Ready</span><span class="ly-scan-phase">DB: ' + (stats.db_size || '0') + '</span></div>'
),
// 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,

View File

@ -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 <<EOF
{
"songs": ${songs:-0},
"albums": ${albums:-0},
"artists": ${artists:-0},
"genres": ${genres:-0},
"scanning": $scanning,
"scan_progress": ${progress_done:-0},
"scan_total": ${progress_total:-0},
"scan_phase": "$progress_name",
"db_size": "$db_size"
}
EOF
}
# Trigger library rescan
do_rescan() {
local port=$(uci_get port)
port=${port:-9000}
curl -s --max-time 5 "http://127.0.0.1:${port}/jsonrpc.js" \
-H "Content-Type: application/json" \
-d '{"id":1,"method":"slim.request","params":["", ["rescan", "full"]]}' >/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
;;
*)

View File

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

View File

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

View File

@ -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 = '<div class="kiss-index-header"><span class="kiss-index-title kiss-index-idle">✓ Ready</span><span class="kiss-index-file">DB: ' + (idx.db_size || '0') + '</span></div>';
}
}
},
handleSaveApply: null,

View File

@ -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 <<EOF
{
"indexing": $indexing,
"sidecar_count": $sidecar_count,
"thumbnail_count": $thumbnail_count,
"db_size": "$db_size",
"current_file": "$current_file",
"last_activity": "$last_activity"
}
EOF
}
# Method: start
method_start() {
/etc/init.d/photoprism start >/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 ;;

View File

@ -7,6 +7,7 @@
"status",
"get_config",
"get_stats",
"get_index_progress",
"logs"
]
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <<EOF
SecuBox Avatar Tap - Session Capture and Replay
Usage: $PROG_NAME <command> [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 <id> Show session details
replay <id> <url> [method] Replay session to URL
label <id> <label> Label a session
delete <id> Delete a session
cleanup [days] Clean up old sessions (default: 7 days)
export <id> <file> Export session to JSON
stats Show capture statistics
config Show configuration
Examples:
$PROG_NAME start
$PROG_NAME list photos.gk2
$PROG_NAME replay 5 https://photos.gk2.secubox.in/api/v1/config
$PROG_NAME label 5 "PhotoPrism Admin"
EOF
}
cmd_start() {
local enabled
config_get enabled main enabled '0'
if [ "$enabled" != "1" ]; then
echo "Avatar Tap is disabled. Enable with:"
echo " uci set avatar-tap.main.enabled=1"
echo " uci commit avatar-tap"
return 1
fi
if pgrep -f "mitmdump.*tap.py" >/dev/null; then
echo "Avatar Tap is already running"
return 0
fi
local port addr mode
config_get port main listen_port '8888'
config_get 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'
mkdir -p "$(dirname "$DB_PATH")"
echo "Starting Avatar Tap on $addr:$port (mode: $mode)"
export AVATAR_TAP_DB="$DB_PATH"
mitmdump -s "$TAP_SCRIPT" -p "$port" --listen-host "$addr" --mode "$mode" \
>/var/log/avatar-tap.log 2>&1 &
echo $! > "$PID_FILE"
sleep 1
if pgrep -f "mitmdump.*tap.py" >/dev/null; then
echo "Avatar Tap started (PID: $(cat $PID_FILE))"
else
echo "Failed to start Avatar Tap"
return 1
fi
}
cmd_stop() {
if [ -f "$PID_FILE" ]; then
kill "$(cat $PID_FILE)" 2>/dev/null
rm -f "$PID_FILE"
fi
pkill -f "mitmdump.*tap.py" 2>/dev/null
echo "Avatar Tap stopped"
}
cmd_restart() {
cmd_stop
sleep 1
cmd_start
}
cmd_status() {
echo "=== Avatar Tap Status ==="
echo ""
if pgrep -f "mitmdump.*tap.py" >/dev/null; then
echo "Status: RUNNING"
echo "PID: $(pgrep -f 'mitmdump.*tap.py')"
else
echo "Status: STOPPED"
fi
echo ""
local port
config_get port main listen_port '8888'
echo "Listen Port: $port"
if [ -f "$DB_PATH" ]; then
local count=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions" 2>/dev/null)
echo "Sessions: ${count:-0}"
local recent=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions WHERE captured_at > strftime('%s','now','-1 hour')" 2>/dev/null)
echo "Last Hour: ${recent:-0}"
else
echo "Sessions: (no database)"
fi
}
cmd_list() {
export AVATAR_TAP_DB="$DB_PATH"
if [ -n "$1" ]; then
python3 "$REPLAY_SCRIPT" list -d "$1"
else
python3 "$REPLAY_SCRIPT" list
fi
}
cmd_show() {
[ -z "$1" ] && { echo "Usage: $PROG_NAME show <session_id>"; return 1; }
export AVATAR_TAP_DB="$DB_PATH"
python3 "$REPLAY_SCRIPT" show "$1"
}
cmd_replay() {
[ -z "$1" ] || [ -z "$2" ] && { echo "Usage: $PROG_NAME replay <session_id> <url> [method]"; return 1; }
export AVATAR_TAP_DB="$DB_PATH"
if [ -n "$3" ]; then
python3 "$REPLAY_SCRIPT" replay "$1" "$2" -m "$3"
else
python3 "$REPLAY_SCRIPT" replay "$1" "$2"
fi
}
cmd_label() {
[ -z "$1" ] || [ -z "$2" ] && { echo "Usage: $PROG_NAME label <session_id> <label>"; return 1; }
export AVATAR_TAP_DB="$DB_PATH"
python3 "$REPLAY_SCRIPT" label "$1" "$2"
}
cmd_delete() {
[ -z "$1" ] && { echo "Usage: $PROG_NAME delete <session_id>"; return 1; }
export AVATAR_TAP_DB="$DB_PATH"
python3 "$REPLAY_SCRIPT" delete "$1"
}
cmd_cleanup() {
local days="${1:-7}"
export AVATAR_TAP_DB="$DB_PATH"
python3 "$REPLAY_SCRIPT" cleanup -d "$days"
}
cmd_export() {
[ -z "$1" ] || [ -z "$2" ] && { echo "Usage: $PROG_NAME export <session_id> <file>"; return 1; }
export AVATAR_TAP_DB="$DB_PATH"
python3 "$REPLAY_SCRIPT" export "$1" "$2"
}
cmd_stats() {
echo "=== Avatar Tap Statistics ==="
echo ""
if [ ! -f "$DB_PATH" ]; then
echo "No database found"
return 1
fi
echo "Total Sessions: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM sessions')"
echo "Unique Domains: $(sqlite3 "$DB_PATH" 'SELECT COUNT(DISTINCT domain) FROM sessions')"
echo "Total Replays: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM replay_log')"
echo ""
echo "Top Domains:"
sqlite3 "$DB_PATH" "SELECT domain, COUNT(*) as cnt FROM sessions GROUP BY domain ORDER BY cnt DESC LIMIT 10" | \
while IFS='|' read domain count; do
printf " %-35s %d\n" "$domain" "$count"
done
echo ""
echo "Recent Activity:"
sqlite3 "$DB_PATH" "SELECT domain, datetime(captured_at,'unixepoch') FROM sessions ORDER BY captured_at DESC LIMIT 5" | \
while IFS='|' read domain ts; do
printf " %-35s %s\n" "$domain" "$ts"
done
}
cmd_config() {
echo "=== Avatar Tap Configuration ==="
uci show avatar-tap
}
# JSON output for RPCD
cmd_json_status() {
local running=0
pgrep -f "mitmdump.*tap.py" >/dev/null && running=1
local sessions=0
local recent=0
if [ -f "$DB_PATH" ]; then
sessions=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions" 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)
fi
local port
config_get port main listen_port '8888'
cat <<EOF
{
"running": $running,
"port": $port,
"sessions": $sessions,
"recent": $recent,
"db_path": "$DB_PATH"
}
EOF
}
cmd_json_list() {
if [ ! -f "$DB_PATH" ]; then
echo "[]"
return
fi
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 50" 2>/dev/null || echo "[]"
}
# Main
case "$1" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
list) cmd_list "$2" ;;
show) cmd_show "$2" ;;
replay) cmd_replay "$2" "$3" "$4" ;;
label) cmd_label "$2" "$3" ;;
delete) cmd_delete "$2" ;;
cleanup) cmd_cleanup "$2" ;;
export) cmd_export "$2" "$3" ;;
stats) cmd_stats ;;
config) cmd_config ;;
json-status) cmd_json_status ;;
json-list) cmd_json_list ;;
*) usage ;;
esac

View File

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""
SecuBox Avatar Replay - Session Replay Utility
Replays captured sessions for authentication relay.
Supports Nitrokey/GPG verification for secure replay authorization.
"""
import sqlite3
import json
import time
import sys
import os
import argparse
import requests
from urllib.parse import urlparse
# Disable SSL warnings for internal services
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DB_PATH = os.environ.get("AVATAR_TAP_DB", "/srv/avatar-tap/sessions.db")
def get_db():
"""Get database connection."""
return sqlite3.connect(DB_PATH)
def list_sessions(domain_filter=None, limit=20):
"""List captured sessions."""
conn = get_db()
conn.row_factory = sqlite3.Row
query = """
SELECT id, domain, path, method, captured_at, last_used, use_count, label, avatar_id
FROM sessions
"""
params = []
if domain_filter:
query += " WHERE domain LIKE ?"
params.append(f"%{domain_filter}%")
query += " ORDER BY captured_at DESC LIMIT ?"
params.append(limit)
cur = conn.execute(query, params)
sessions = cur.fetchall()
print(f"{'ID':>4} {'Domain':<30} {'Method':<6} {'Path':<30} {'Label':<15} {'Uses':>4}")
print("-" * 100)
for s in sessions:
path = (s['path'] or '/')[:28]
label = (s['label'] or '-')[:13]
captured = time.strftime('%m/%d %H:%M', time.localtime(s['captured_at']))
print(f"{s['id']:>4} {s['domain']:<30} {s['method']:<6} {path:<30} {label:<15} {s['use_count']:>4}")
return sessions
def show_session(session_id):
"""Show detailed session info."""
conn = get_db()
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
s = cur.fetchone()
if not s:
print(f"Session {session_id} not found")
return None
print(f"Session #{s['id']}")
print(f" Domain: {s['domain']}")
print(f" Path: {s['path']}")
print(f" Method: {s['method']}")
print(f" Captured: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(s['captured_at']))}")
print(f" Last Used: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(s['last_used']))}")
print(f" Use Count: {s['use_count']}")
print(f" Label: {s['label'] or '-'}")
print(f" Avatar ID: {s['avatar_id'] or '-'}")
print(f" Verified: {'Yes' if s['verified'] else 'No'}")
print(f"\n Cookies:")
cookies = s['cookies']
if cookies:
for c in cookies.split(';'):
print(f" {c.strip()[:60]}")
print(f"\n Auth Headers:")
headers = json.loads(s['headers'] or '{}')
for k, v in headers.items():
print(f" {k}: {v[:50]}...")
return dict(s)
def replay_session(session_id, target_url, method=None, output_file=None):
"""Replay a captured session to target URL."""
conn = get_db()
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
s = cur.fetchone()
if not s:
print(f"Session {session_id} not found")
return None
# Build headers
headers = json.loads(s['headers'] or '{}')
if s['cookies']:
headers['cookie'] = s['cookies']
if s['user_agent']:
headers['user-agent'] = s['user_agent']
# Use stored method or override
req_method = method or s['method'] or 'GET'
print(f"Replaying session #{session_id} to {target_url}")
print(f" Method: {req_method}")
print(f" Headers: {len(headers)} auth headers")
try:
if req_method.upper() == 'GET':
resp = requests.get(target_url, headers=headers, verify=False, timeout=30)
elif req_method.upper() == 'POST':
resp = requests.post(target_url, headers=headers, verify=False, timeout=30)
else:
resp = requests.request(req_method, target_url, headers=headers, verify=False, timeout=30)
print(f"\n Status: {resp.status_code}")
print(f" Content-Type: {resp.headers.get('content-type', 'unknown')}")
print(f" Content-Length: {len(resp.content)} bytes")
# Update usage stats
conn.execute(
"UPDATE sessions SET use_count = use_count + 1, last_used = ? WHERE id = ?",
(int(time.time()), session_id)
)
conn.execute(
"INSERT INTO replay_log (session_id, target_url, status_code, replayed_at) VALUES (?, ?, ?, ?)",
(session_id, target_url, resp.status_code, int(time.time()))
)
conn.commit()
# Output response
if output_file:
with open(output_file, 'wb') as f:
f.write(resp.content)
print(f"\n Response saved to: {output_file}")
else:
content_type = resp.headers.get('content-type', '')
if 'json' in content_type:
try:
print(f"\n Response (JSON):")
print(json.dumps(resp.json(), indent=2)[:2000])
except:
print(f"\n Response (raw):")
print(resp.text[:1000])
elif 'text' in content_type or 'html' in content_type:
print(f"\n Response (text):")
print(resp.text[:1000])
else:
print(f"\n Response: <binary {len(resp.content)} bytes>")
return resp
except Exception as e:
print(f" Error: {e}")
return None
def label_session(session_id, label, avatar_id=None):
"""Label a session for organization."""
conn = get_db()
if avatar_id:
conn.execute(
"UPDATE sessions SET label = ?, avatar_id = ? WHERE id = ?",
(label, avatar_id, session_id)
)
else:
conn.execute("UPDATE sessions SET label = ? WHERE id = ?", (label, session_id))
conn.commit()
print(f"Session #{session_id} labeled: {label}")
def delete_session(session_id):
"""Delete a session."""
conn = get_db()
conn.execute("DELETE FROM replay_log WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
print(f"Session #{session_id} deleted")
def cleanup_old(days=7):
"""Clean up sessions older than N days."""
conn = get_db()
cutoff = int(time.time()) - (days * 86400)
cur = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE captured_at < ? AND label IS NULL",
(cutoff,)
)
count = cur.fetchone()[0]
if count > 0:
conn.execute(
"DELETE FROM sessions WHERE captured_at < ? AND label IS NULL",
(cutoff,)
)
conn.commit()
print(f"Cleaned up {count} unlabeled sessions older than {days} days")
else:
print("No sessions to clean up")
def export_session(session_id, output_file):
"""Export session to JSON file."""
conn = get_db()
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
s = cur.fetchone()
if not s:
print(f"Session {session_id} not found")
return
data = dict(s)
data['headers'] = json.loads(data['headers'] or '{}')
with open(output_file, 'w') as f:
json.dump(data, f, indent=2)
print(f"Session #{session_id} exported to {output_file}")
def main():
parser = argparse.ArgumentParser(description='SecuBox Avatar Session Replay')
subparsers = parser.add_subparsers(dest='command', help='Commands')
# List command
list_parser = subparsers.add_parser('list', help='List captured sessions')
list_parser.add_argument('-d', '--domain', help='Filter by domain')
list_parser.add_argument('-n', '--limit', type=int, default=20, help='Number of sessions')
# Show command
show_parser = subparsers.add_parser('show', help='Show session details')
show_parser.add_argument('session_id', type=int, help='Session ID')
# Replay command
replay_parser = subparsers.add_parser('replay', help='Replay session')
replay_parser.add_argument('session_id', type=int, help='Session ID')
replay_parser.add_argument('url', help='Target URL')
replay_parser.add_argument('-m', '--method', help='HTTP method override')
replay_parser.add_argument('-o', '--output', help='Save response to file')
# Label command
label_parser = subparsers.add_parser('label', help='Label a session')
label_parser.add_argument('session_id', type=int, help='Session ID')
label_parser.add_argument('label', help='Label text')
label_parser.add_argument('-a', '--avatar', help='Avatar ID')
# Delete command
delete_parser = subparsers.add_parser('delete', help='Delete session')
delete_parser.add_argument('session_id', type=int, help='Session ID')
# Cleanup command
cleanup_parser = subparsers.add_parser('cleanup', help='Clean old sessions')
cleanup_parser.add_argument('-d', '--days', type=int, default=7, help='Age in days')
# Export command
export_parser = subparsers.add_parser('export', help='Export session to JSON')
export_parser.add_argument('session_id', type=int, help='Session ID')
export_parser.add_argument('output', help='Output file')
args = parser.parse_args()
if args.command == 'list':
list_sessions(args.domain, args.limit)
elif args.command == 'show':
show_session(args.session_id)
elif args.command == 'replay':
replay_session(args.session_id, args.url, args.method, args.output)
elif args.command == 'label':
label_session(args.session_id, args.label, args.avatar)
elif args.command == 'delete':
delete_session(args.session_id)
elif args.command == 'cleanup':
cleanup_old(args.days)
elif args.command == 'export':
export_session(args.session_id, args.output)
else:
# Default: list sessions
list_sessions()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,147 @@
"""
SecuBox Avatar Tap - Passive Session Capture Addon for mitmproxy
Captures authentication-related headers and cookies for session replay.
Designed to work with Nitrokey/GPG for secure authentication relay.
"""
from mitmproxy import http, ctx
import sqlite3
import json
import time
import os
import hashlib
DB_PATH = os.environ.get("AVATAR_TAP_DB", "/srv/avatar-tap/sessions.db")
# Headers to capture for authentication
AUTH_HEADERS = [
"authorization",
"x-auth-token",
"x-access-token",
"x-api-key",
"x-csrf-token",
"x-xsrf-token",
"bearer",
"www-authenticate",
]
def init_db():
"""Initialize SQLite database for session storage."""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.execute('''CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_hash TEXT UNIQUE,
domain TEXT NOT NULL,
path TEXT,
method TEXT,
cookies TEXT,
headers TEXT,
user_agent TEXT,
captured_at INTEGER,
last_used INTEGER,
use_count INTEGER DEFAULT 0,
label TEXT,
avatar_id TEXT,
verified INTEGER DEFAULT 0
)''')
conn.execute('''CREATE INDEX IF NOT EXISTS idx_domain ON sessions(domain)''')
conn.execute('''CREATE INDEX IF NOT EXISTS idx_captured ON sessions(captured_at)''')
conn.execute('''CREATE TABLE IF NOT EXISTS replay_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER,
target_url TEXT,
status_code INTEGER,
replayed_at INTEGER,
FOREIGN KEY(session_id) REFERENCES sessions(id)
)''')
conn.commit()
return conn
def compute_session_hash(domain, cookies, auth_headers):
"""Compute unique hash for session deduplication."""
data = f"{domain}:{cookies}:{json.dumps(auth_headers, sort_keys=True)}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
class AvatarTap:
"""Mitmproxy addon for passive session capture."""
def __init__(self):
self.db = init_db()
ctx.log.info(f"[AvatarTap] Initialized with DB: {DB_PATH}")
def request(self, flow: http.HTTPFlow):
"""Capture authentication data from requests."""
domain = flow.request.host
path = flow.request.path
method = flow.request.method
# Extract cookies
cookies = flow.request.headers.get("cookie", "")
# Extract auth-related headers
auth_headers = {}
for header in AUTH_HEADERS:
value = flow.request.headers.get(header)
if value:
auth_headers[header] = value
# Also capture custom headers that look like tokens
for key, value in flow.request.headers.items():
key_lower = key.lower()
if any(x in key_lower for x in ["token", "auth", "session", "key", "bearer"]):
if key_lower not in auth_headers:
auth_headers[key_lower] = value
# Skip if no auth data
if not cookies and not auth_headers:
return
# Compute session hash for deduplication
session_hash = compute_session_hash(domain, cookies, auth_headers)
# Get user agent
user_agent = flow.request.headers.get("user-agent", "")
try:
# Check if session already exists
cur = self.db.execute(
"SELECT id FROM sessions WHERE session_hash = ?",
(session_hash,)
)
existing = cur.fetchone()
if existing:
# Update last seen
self.db.execute(
"UPDATE sessions SET last_used = ? WHERE id = ?",
(int(time.time()), existing[0])
)
else:
# Insert new session
self.db.execute(
"""INSERT INTO sessions
(session_hash, domain, path, method, cookies, headers, user_agent, captured_at, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(session_hash, domain, path, method, cookies,
json.dumps(auth_headers), user_agent,
int(time.time()), int(time.time()))
)
ctx.log.info(f"[AvatarTap] Captured session for {domain} ({method} {path[:50]})")
self.db.commit()
except Exception as e:
ctx.log.error(f"[AvatarTap] DB error: {e}")
def response(self, flow: http.HTTPFlow):
"""Capture Set-Cookie headers from responses."""
set_cookies = flow.response.headers.get_all("set-cookie")
if set_cookies:
domain = flow.request.host
ctx.log.debug(f"[AvatarTap] Captured {len(set_cookies)} Set-Cookie for {domain}")
addons = [AvatarTap()]