feat(portal): Add dynamic services discovery from listening ports

- Add get_services RPCD method to detect listening TCP services
- Map known ports to service names, icons, and categories
- Display clickable service cards in portal Services tab
- Services link directly to their URLs (e.g., :3000 for Gitea)
- Filter to show only externally accessible services with URLs
- Add ACL permissions for portal and admin apps

Detected services include: Gitea, HexoJS, CyberFeed, Streamlit,
HAProxy Stats, Netifyd, LuCI, Lyrion, and more.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-25 08:12:51 +01:00
parent d03f73cb83
commit cc86aa7f84
6 changed files with 246 additions and 6 deletions

View File

@ -119,6 +119,13 @@ var callGetWidgetData = rpc.declare({
expect: { }
});
// Services Discovery
var callGetServices = rpc.declare({
object: 'luci.secubox',
method: 'get_services',
expect: { services: [] }
});
// ===== State Management API =====
var callGetComponentState = rpc.declare({
@ -334,6 +341,9 @@ return baseclass.extend({
// Widget Data
getWidgetData: debugRPC('getWidgetData', callGetWidgetData, { retries: 1 }),
// Services Discovery
getServices: debugRPC('getServices', callGetServices, { retries: 1 }),
// ===== State Management =====
getComponentState: debugRPC('getComponentState', callGetComponentState, { retries: 2 }),
setComponentState: debugRPC('setComponentState', callSetComponentState, { retries: 1 }),

View File

@ -198,6 +198,70 @@ return view.extend({
]);
},
renderServicesSection: function(services) {
if (!services || services.length === 0) {
return E('div', { 'class': 'services-section card' }, [
E('h3', {}, '🔌 Active Services'),
E('p', { 'class': 'text-muted' }, 'No services detected')
]);
}
// Filter for external services only (accessible from network)
var externalServices = services.filter(function(s) {
return s.external && s.url;
});
// Group by category
var categories = {};
externalServices.forEach(function(s) {
var cat = s.category || 'other';
if (!categories[cat]) categories[cat] = [];
categories[cat].push(s);
});
var categoryOrder = ['app', 'monitoring', 'system', 'proxy', 'security', 'media', 'privacy', 'other'];
var categoryLabels = {
app: '📦 Applications',
monitoring: '📊 Monitoring',
system: '⚙️ System',
proxy: '🔀 Proxy',
security: '🛡️ Security',
media: '🎵 Media',
privacy: '🧅 Privacy',
other: '⚡ Other'
};
var serviceLinks = [];
categoryOrder.forEach(function(cat) {
if (categories[cat] && categories[cat].length > 0) {
categories[cat].forEach(function(svc) {
var url = window.location.protocol + '//' + window.location.hostname + svc.url;
serviceLinks.push(E('a', {
'href': url,
'target': '_blank',
'class': 'service-link',
'style': 'display:inline-flex;align-items:center;gap:8px;padding:10px 16px;' +
'background:rgba(102,126,234,0.1);border:1px solid rgba(102,126,234,0.3);' +
'border-radius:8px;text-decoration:none;color:#e0e0e0;font-size:14px;' +
'transition:all 0.2s;margin:4px;'
}, [
E('span', { 'style': 'font-size:18px;' }, svc.icon || '⚡'),
E('span', {}, svc.name),
E('span', { 'style': 'color:#888;font-size:12px;' }, ':' + svc.port)
]));
});
}
});
return E('div', { 'class': 'services-section card' }, [
E('h3', {}, '🔌 Active Services'),
E('div', { 'class': 'services-grid', 'style': 'display:flex;flex-wrap:wrap;gap:8px;' },
serviceLinks.length > 0 ? serviceLinks :
[E('p', { 'class': 'text-muted' }, 'No external services available')]
)
]);
},
renderQuickActions: function() {
return E('div', { 'class': 'quick-actions card' }, [
E('h3', {}, 'Quick Actions'),

View File

@ -18,7 +18,8 @@
"get_app_versions",
"get_changelog",
"get_widget_data",
"get_wan_access"
"get_wan_access",
"get_services"
]
},
"uci": [

View File

@ -23,6 +23,12 @@ var callCrowdSecStats = rpc.declare({
method: 'nftables_stats'
});
var callGetServices = rpc.declare({
object: 'luci.secubox',
method: 'get_services',
expect: { services: [] }
});
return view.extend({
currentSection: 'dashboard',
appStatuses: {},
@ -35,10 +41,14 @@ return view.extend({
callSystemInfo(),
this.loadAppStatuses(),
callCrowdSecStats().catch(function() { return null; }),
portal.checkInstalledApps()
portal.checkInstalledApps(),
callGetServices().catch(function() { return []; })
]).then(function(results) {
// Store installed apps info from the last promise
self.installedApps = results[4] || {};
// RPC expect unwraps the services array directly
var svcResult = results[5] || [];
self.detectedServices = Array.isArray(svcResult) ? svcResult : (svcResult.services || []);
return results;
});
},
@ -407,9 +417,95 @@ return view.extend({
},
renderServicesSection: function() {
var apps = portal.getInstalledAppsBySection('services', this.installedApps);
return this.renderAppSection('services', 'Services',
'Application services and server platforms', apps);
var self = this;
var services = this.detectedServices || [];
// Filter for external services with URLs
var externalServices = services.filter(function(s) {
return s.external && s.url;
});
// Group by category
var categories = {};
externalServices.forEach(function(s) {
var cat = s.category || 'other';
if (!categories[cat]) categories[cat] = [];
categories[cat].push(s);
});
var categoryOrder = ['app', 'monitoring', 'system', 'proxy', 'security', 'media', 'privacy', 'other'];
var categoryLabels = {
app: '📦 Applications',
monitoring: '📊 Monitoring',
system: '⚙️ System',
proxy: '🔀 Proxy',
security: '🛡️ Security',
media: '🎵 Media',
privacy: '🧅 Privacy',
other: '⚡ Other'
};
var serviceCards = [];
// Map icon names to emojis
var iconMap = {
'lock': '🔐', 'globe': '🌐', 'arrow': '🔀', 'shield': '🔒',
'git': '📦', 'blog': '📝', 'security': '🛡️', 'settings': '⚙️',
'feed': '📡', 'chart': '📊', 'stats': '📈', 'admin': '🔧',
'app': '🎨', 'music': '🎵', 'onion': '🧅', '': '⚡'
};
categoryOrder.forEach(function(cat) {
if (categories[cat] && categories[cat].length > 0) {
categories[cat].forEach(function(svc) {
var url = window.location.protocol + '//' + window.location.hostname + svc.url;
var emoji = iconMap[svc.icon] || '⚡';
serviceCards.push(E('a', {
'class': 'sb-app-card sb-service-card',
'href': url,
'target': '_blank'
}, [
E('div', { 'class': 'sb-app-card-header' }, [
E('div', {
'class': 'sb-app-card-icon',
'style': 'background: linear-gradient(135deg, #667eea, #764ba2); font-size: 24px;'
}, emoji),
E('div', {}, [
E('h4', { 'class': 'sb-app-card-title' }, svc.name),
E('span', { 'class': 'sb-app-card-version' }, ':' + svc.port)
])
]),
E('p', { 'class': 'sb-app-card-desc' }, categoryLabels[svc.category] || 'Service'),
E('div', { 'class': 'sb-app-card-status' }, [
E('span', { 'class': 'sb-app-card-status-dot running' }),
E('span', { 'class': 'sb-app-card-status-text' }, 'Listening')
])
]));
});
}
});
if (serviceCards.length === 0) {
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
]),
E('div', { 'class': 'sb-section-empty' }, [
E('div', { 'class': 'sb-empty-icon' }, '🔌'),
E('p', { 'class': 'sb-empty-text' }, 'No external services detected'),
E('p', { 'class': 'sb-empty-hint' }, 'Services listening on localhost only are not shown')
])
]);
}
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
]),
E('div', { 'class': 'sb-app-grid' }, serviceCards)
]);
},
renderAppSection: function(sectionId, title, subtitle, apps) {

View File

@ -10,7 +10,8 @@
"getModuleInfo",
"get_theme",
"getStatus",
"getHealth"
"getHealth",
"get_services"
],
"luci.system-hub": [
"status",

View File

@ -221,6 +221,10 @@ case "$1" in
json_add_object "apply_wan_access"
json_close_object
# Services discovery
json_add_object "get_services"
json_close_object
json_dump
;;
@ -1174,6 +1178,70 @@ case "$1" in
json_dump
;;
get_services)
# Discover listening services from netstat
# Save to temp file to avoid subshell issues with json
TMP_SERVICES="/tmp/services_$$"
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
split($4, a, ":")
port = a[length(a)]
if (!seen[port]++) {
split($7, p, "/")
proc = p[2]
if (proc == "") proc = "unknown"
print port, $4, proc
}
}' | sort -n -u > "$TMP_SERVICES"
json_init
json_add_array "services"
while read port local proc; do
addr=$(echo "$local" | sed 's/:[^:]*$//')
name="Service"; icon=""; category="other"; path=""
case "$port" in
22) name="SSH"; icon="lock"; category="system" ;;
53) name="DNS"; icon="globe"; category="system" ;;
80) name="HTTP"; icon="arrow"; path="/"; category="proxy" ;;
443) name="HTTPS"; icon="shield"; path="/"; category="proxy" ;;
3000) name="Gitea"; icon="git"; path=":3000"; category="app" ;;
4000) name="HexoJS"; icon="blog"; path=":4000"; category="app" ;;
8080) name="CrowdSec"; icon="security"; category="security" ;;
8081) name="LuCI"; icon="settings"; path=":8081"; category="system" ;;
8082) name="CyberFeed"; icon="feed"; path=":8082"; category="app" ;;
8086) name="Netifyd"; icon="chart"; path=":8086"; category="monitoring" ;;
8404) name="HAProxy Stats"; icon="stats"; path=":8404/stats"; category="monitoring" ;;
8444) name="LuCI HTTPS"; icon="admin"; path=":8444"; category="system" ;;
8501) name="Streamlit"; icon="app"; path=":8501"; category="app" ;;
9000) name="Lyrion"; icon="music"; path=":9000"; category="media" ;;
9050) name="Tor SOCKS"; icon="onion"; category="privacy" ;;
9090) name="Lyrion CLI"; icon="music"; category="media" ;;
2222) name="Gitea SSH"; icon="git"; category="app" ;;
3483) name="Squeezebox"; icon="music"; category="media" ;;
esac
external=0
case "$addr" in 0.0.0.0|::) external=1 ;; 127.0.0.1|::1) ;; *) external=1 ;; esac
json_add_object ""
json_add_int "port" "$port"
json_add_string "address" "$addr"
json_add_string "name" "$name"
json_add_string "icon" "$icon"
json_add_string "process" "$proc"
json_add_string "category" "$category"
json_add_boolean "external" "$external"
[ -n "$path" ] && [ "$external" = "1" ] && json_add_string "url" "$path"
json_close_object
done < "$TMP_SERVICES"
rm -f "$TMP_SERVICES"
json_close_array
json_dump
;;
*)
json_init
json_add_boolean "error" true