feat(service-registry): Add unified service aggregation dashboard

Implement Service Registry LuCI app for unified service management:
- RPCD backend aggregating services from HAProxy, Tor, netstat, LXC
- One-click publish to clearnet (HAProxy+ACME) and/or Tor hidden service
- Static landing page generator with QR codes for all URLs
- LuCI dashboard with service grid, quick publish form
- CLI tool (secubox-registry) for command-line management
- Share buttons for X, Telegram, WhatsApp

RPCD methods: list_services, publish_service, unpublish_service,
generate_landing_page, get_qr_data, list_categories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-28 05:04:26 +01:00
parent 0f6953ad06
commit ccba39da62
13 changed files with 3551 additions and 0 deletions

View File

@ -0,0 +1,52 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2025 CyberMind.fr
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Service Registry
LUCI_DESCRIPTION:=Unified service aggregation with HAProxy vhosts, Tor hidden services, and QR-coded landing page
LUCI_DEPENDS:=+secubox-core +luci-base +luci-compat
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-service-registry
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT
# File permissions - RPCD script and CLI tools must be executable
PKG_FILE_MODES:=/usr/libexec/rpcd/luci.service-registry:root:root:755 \
/usr/sbin/secubox-registry:root:root:755 \
/usr/sbin/secubox-landing-gen:root:root:755
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-service-registry/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.service-registry $(1)/usr/libexec/rpcd/luci.service-registry
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./root/usr/sbin/secubox-registry $(1)/usr/sbin/secubox-registry
$(INSTALL_BIN) ./root/usr/sbin/secubox-landing-gen $(1)/usr/sbin/secubox-landing-gen
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-service-registry.json $(1)/usr/share/luci/menu.d/luci-app-service-registry.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-service-registry.json $(1)/usr/share/rpcd/acl.d/luci-app-service-registry.json
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/service-registry $(1)/etc/config/service-registry
$(INSTALL_DIR) $(1)/www/luci-static/resources/service-registry
$(INSTALL_DATA) ./htdocs/luci-static/resources/service-registry/api.js $(1)/www/luci-static/resources/service-registry/api.js
$(INSTALL_DATA) ./htdocs/luci-static/resources/service-registry/registry.css $(1)/www/luci-static/resources/service-registry/registry.css
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/service-registry
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/service-registry/*.js $(1)/www/luci-static/resources/view/service-registry/
$(INSTALL_DIR) $(1)/www
$(INSTALL_DATA) ./root/www/secubox-services.html $(1)/www/secubox-services.html
endef
$(eval $(call BuildPackage,luci-app-service-registry))

View File

@ -0,0 +1,226 @@
'use strict';
'require baseclass';
'require rpc';
// RPC method declarations
var callListServices = rpc.declare({
object: 'luci.service-registry',
method: 'list_services',
expect: { services: [], providers: {} }
});
var callGetService = rpc.declare({
object: 'luci.service-registry',
method: 'get_service',
params: ['service_id'],
expect: {}
});
var callPublishService = rpc.declare({
object: 'luci.service-registry',
method: 'publish_service',
params: ['name', 'local_port', 'domain', 'tor_enabled', 'category', 'icon'],
expect: {}
});
var callUnpublishService = rpc.declare({
object: 'luci.service-registry',
method: 'unpublish_service',
params: ['service_id'],
expect: {}
});
var callUpdateService = rpc.declare({
object: 'luci.service-registry',
method: 'update_service',
params: ['service_id', 'name', 'category', 'icon'],
expect: {}
});
var callDeleteService = rpc.declare({
object: 'luci.service-registry',
method: 'delete_service',
params: ['service_id'],
expect: {}
});
var callSyncProviders = rpc.declare({
object: 'luci.service-registry',
method: 'sync_providers',
expect: {}
});
var callGenerateLandingPage = rpc.declare({
object: 'luci.service-registry',
method: 'generate_landing_page',
expect: {}
});
var callGetQrData = rpc.declare({
object: 'luci.service-registry',
method: 'get_qr_data',
params: ['service_id', 'url_type'],
expect: {}
});
var callListCategories = rpc.declare({
object: 'luci.service-registry',
method: 'list_categories',
expect: { categories: [] }
});
var callGetCertificateStatus = rpc.declare({
object: 'luci.service-registry',
method: 'get_certificate_status',
params: ['service_id'],
expect: {}
});
var callGetLandingConfig = rpc.declare({
object: 'luci.service-registry',
method: 'get_landing_config',
expect: {}
});
var callSaveLandingConfig = rpc.declare({
object: 'luci.service-registry',
method: 'save_landing_config',
params: ['auto_regen'],
expect: {}
});
// HAProxy status for provider info
var callHAProxyStatus = rpc.declare({
object: 'luci.haproxy',
method: 'status',
expect: {}
});
// Tor Shield status for provider info
var callTorStatus = rpc.declare({
object: 'luci.tor-shield',
method: 'status',
expect: {}
});
return baseclass.extend({
// List all services from all providers
listServices: function() {
return callListServices();
},
// Get single service details
getService: function(serviceId) {
return callGetService(serviceId);
},
// Publish a new service
publishService: function(name, localPort, domain, torEnabled, category, icon) {
return callPublishService(
name,
parseInt(localPort) || 0,
domain || '',
torEnabled ? true : false,
category || 'services',
icon || ''
);
},
// Unpublish a service
unpublishService: function(serviceId) {
return callUnpublishService(serviceId);
},
// Update service metadata
updateService: function(serviceId, name, category, icon) {
return callUpdateService(serviceId, name || '', category || '', icon || '');
},
// Delete a service
deleteService: function(serviceId) {
return callDeleteService(serviceId);
},
// Sync all providers
syncProviders: function() {
return callSyncProviders();
},
// Generate landing page
generateLandingPage: function() {
return callGenerateLandingPage();
},
// Get QR code data for a service URL
getQrData: function(serviceId, urlType) {
return callGetQrData(serviceId, urlType || 'local');
},
// List available categories
listCategories: function() {
return callListCategories();
},
// Get certificate status for a service
getCertificateStatus: function(serviceId) {
return callGetCertificateStatus(serviceId);
},
// Get landing page configuration
getLandingConfig: function() {
return callGetLandingConfig();
},
// Save landing page configuration
saveLandingConfig: function(autoRegen) {
return callSaveLandingConfig(autoRegen ? true : false);
},
// Get dashboard data (services + provider status)
getDashboardData: function() {
return Promise.all([
callListServices(),
callListCategories(),
callGetLandingConfig(),
callHAProxyStatus().catch(function() { return { enabled: false }; }),
callTorStatus().catch(function() { return { enabled: false }; })
]).then(function(results) {
return {
services: results[0].services || [],
providers: results[0].providers || {},
categories: results[1].categories || [],
landing: results[2],
haproxy: results[3],
tor: results[4]
};
});
},
// Get published services only
getPublishedServices: function() {
return callListServices().then(function(data) {
return (data.services || []).filter(function(s) {
return s.published;
});
});
},
// Get unpublished (discoverable) services
getUnpublishedServices: function() {
return callListServices().then(function(data) {
return (data.services || []).filter(function(s) {
return !s.published;
});
});
},
// Quick publish with defaults
quickPublish: function(name, port) {
return this.publishService(name, port, '', false, 'services', '');
},
// Full publish with HAProxy + Tor
fullPublish: function(name, port, domain) {
return this.publishService(name, port, domain, true, 'services', '');
}
});

View File

@ -0,0 +1,435 @@
/* Service Registry Dashboard Styles */
.sr-dashboard {
padding: 10px 0;
}
/* Stats row */
.sr-stats {
display: flex;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.sr-stat-card {
background: var(--cbi-section-bg, #fff);
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 8px;
padding: 15px 20px;
min-width: 150px;
text-align: center;
}
.sr-stat-value {
font-size: 2em;
font-weight: bold;
color: var(--primary-color, #0099cc);
}
.sr-stat-label {
font-size: 0.85em;
color: var(--secondary-text-color, #666);
margin-top: 5px;
}
/* Provider status indicators */
.sr-providers {
display: flex;
gap: 15px;
margin-bottom: 25px;
flex-wrap: wrap;
}
.sr-provider {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
background: var(--cbi-section-bg, #fff);
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 6px;
font-size: 0.9em;
}
.sr-provider-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.sr-provider-dot.running { background: #22c55e; }
.sr-provider-dot.stopped { background: #ef4444; }
.sr-provider-dot.unknown { background: #a1a1aa; }
/* Service grid */
.sr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
margin-top: 20px;
}
/* Service card */
.sr-card {
background: var(--cbi-section-bg, #fff);
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 10px;
padding: 20px;
transition: box-shadow 0.2s, border-color 0.2s;
}
.sr-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color, #0099cc);
}
.sr-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.sr-card-icon {
font-size: 1.5em;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--cbi-section-alt-bg, #f5f5f5);
border-radius: 8px;
}
.sr-card-title {
font-weight: 600;
font-size: 1.1em;
flex: 1;
}
.sr-card-status {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75em;
font-weight: 500;
}
.sr-status-running {
background: #dcfce7;
color: #166534;
}
.sr-status-stopped {
background: #fee2e2;
color: #991b1b;
}
/* URL list */
.sr-urls {
margin: 15px 0;
}
.sr-url-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--cbi-section-alt-bg, #f9f9f9);
border-radius: 6px;
margin-bottom: 8px;
}
.sr-url-label {
min-width: 65px;
font-size: 0.75em;
color: var(--secondary-text-color, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sr-url-link {
flex: 1;
color: var(--primary-color, #0099cc);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr-url-link:hover {
text-decoration: underline;
}
.sr-copy-btn {
padding: 4px 8px;
font-size: 0.8em;
cursor: pointer;
}
/* QR codes */
.sr-qr-container {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--cbi-border-color, #ddd);
flex-wrap: wrap;
}
.sr-qr-box {
text-align: center;
}
.sr-qr-code {
background: #fff;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--cbi-border-color, #ddd);
display: inline-block;
}
.sr-qr-label {
font-size: 0.7em;
color: var(--secondary-text-color, #666);
margin-top: 6px;
text-transform: uppercase;
}
/* Quick publish form */
.sr-quick-publish {
background: var(--cbi-section-bg, #fff);
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 10px;
padding: 20px;
margin-bottom: 25px;
}
.sr-quick-publish h3 {
margin-bottom: 15px;
font-size: 1.1em;
}
.sr-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
}
.sr-form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.sr-form-group label {
font-size: 0.85em;
color: var(--secondary-text-color, #666);
}
.sr-form-group input[type="text"],
.sr-form-group input[type="number"] {
padding: 8px 12px;
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 6px;
min-width: 150px;
}
.sr-form-group input:focus {
outline: none;
border-color: var(--primary-color, #0099cc);
}
.sr-checkbox-group {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.sr-checkbox-group input[type="checkbox"] {
width: 16px;
height: 16px;
}
/* Category filter */
.sr-category-filter {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.sr-category-btn {
padding: 6px 14px;
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 20px;
background: var(--cbi-section-bg, #fff);
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
}
.sr-category-btn:hover,
.sr-category-btn.active {
background: var(--primary-color, #0099cc);
color: #fff;
border-color: var(--primary-color, #0099cc);
}
/* Published modal */
.sr-published-modal {
text-align: center;
padding: 20px;
}
.sr-published-modal h3 {
margin-bottom: 20px;
color: #22c55e;
}
.sr-url-box {
margin: 15px 0;
text-align: left;
}
.sr-url-box label {
display: block;
font-size: 0.85em;
color: var(--secondary-text-color, #666);
margin-bottom: 5px;
}
.sr-url-box input {
width: 100%;
padding: 10px;
border: 1px solid var(--cbi-border-color, #ddd);
border-radius: 6px;
font-family: monospace;
background: var(--cbi-section-alt-bg, #f9f9f9);
}
/* Share buttons */
.sr-share-buttons {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 20px;
}
.sr-share-buttons a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--cbi-section-alt-bg, #f5f5f5);
color: var(--primary-text-color, #333);
text-decoration: none;
font-weight: bold;
transition: all 0.2s;
}
.sr-share-buttons a:hover {
background: var(--primary-color, #0099cc);
color: #fff;
}
/* Landing page link */
.sr-landing-link {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 30px;
padding: 15px;
background: var(--cbi-section-alt-bg, #f5f5f5);
border-radius: 8px;
}
.sr-landing-link a {
color: var(--primary-color, #0099cc);
font-weight: 500;
}
/* Actions dropdown */
.sr-card-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--cbi-border-color, #ddd);
}
.sr-card-actions button {
flex: 1;
padding: 6px 10px;
font-size: 0.85em;
}
/* Empty state */
.sr-empty {
text-align: center;
padding: 60px 20px;
color: var(--secondary-text-color, #666);
}
.sr-empty h3 {
margin-bottom: 10px;
}
.sr-empty p {
margin-bottom: 20px;
}
/* Responsive */
@media (max-width: 768px) {
.sr-grid {
grid-template-columns: 1fr;
}
.sr-stats {
justify-content: center;
}
.sr-form {
flex-direction: column;
}
.sr-form-group {
width: 100%;
}
.sr-form-group input {
width: 100%;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.sr-card,
.sr-quick-publish,
.sr-stat-card,
.sr-provider {
background: #1e1e2e;
border-color: #333;
}
.sr-url-row,
.sr-landing-link {
background: #252535;
}
.sr-status-running {
background: #064e3b;
color: #6ee7b7;
}
.sr-status-stopped {
background: #7f1d1d;
color: #fca5a5;
}
}

View File

@ -0,0 +1,199 @@
'use strict';
'require view';
'require dom';
'require ui';
'require form';
'require fs';
'require service-registry/api as api';
return view.extend({
title: _('Landing Page'),
load: function() {
return Promise.all([
api.getLandingConfig(),
api.getPublishedServices()
]);
},
render: function(data) {
var self = this;
var config = data[0] || {};
var services = data[1] || [];
// Load CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('service-registry/registry.css');
document.head.appendChild(link);
var m, s, o;
m = new form.Map('service-registry', _('Landing Page Configuration'),
_('Configure the public landing page that displays all published services with QR codes.'));
s = m.section(form.NamedSection, 'main', 'settings', _('Settings'));
o = s.option(form.Flag, 'landing_auto_regen', _('Auto-regenerate'),
_('Automatically regenerate landing page when services are published or unpublished'));
o.default = '1';
o = s.option(form.Value, 'landing_path', _('Landing Page Path'),
_('File path where the landing page will be generated'));
o.default = '/www/secubox-services.html';
o.readonly = true;
return m.render().then(function(mapEl) {
// Status section
var statusSection = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Status')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('File exists')),
E('div', { 'class': 'cbi-value-field' },
config.exists ?
E('span', { 'style': 'color: #22c55e;' }, _('Yes')) :
E('span', { 'style': 'color: #ef4444;' }, _('No'))
)
]),
config.exists && config.modified ? E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Last modified')),
E('div', { 'class': 'cbi-value-field' },
new Date(config.modified * 1000).toLocaleString()
)
]) : null,
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Published services')),
E('div', { 'class': 'cbi-value-field' }, String(services.length))
])
].filter(Boolean));
// Actions section
var actionsSection = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Actions')),
E('div', { 'style': 'display: flex; gap: 15px; flex-wrap: wrap;' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, 'handleRegenerate')
}, _('Regenerate Landing Page')),
config.exists ? E('a', {
'class': 'cbi-button',
'href': '/secubox-services.html',
'target': '_blank'
}, _('View Landing Page')) : null,
config.exists ? E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(self, 'handlePreview')
}, _('Preview')) : null
].filter(Boolean))
]);
// Services preview
var previewSection = null;
if (services.length > 0) {
previewSection = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Services on Landing Page')),
E('p', { 'style': 'color: #666; margin-bottom: 15px;' },
_('These services will be displayed on the landing page:')),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Name')),
E('th', { 'class': 'th' }, _('Category')),
E('th', { 'class': 'th' }, _('Status')),
E('th', { 'class': 'th' }, _('Clearnet')),
E('th', { 'class': 'th' }, _('Onion'))
])
].concat(services.map(function(svc) {
var urls = svc.urls || {};
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, svc.name || svc.id),
E('td', { 'class': 'td' }, svc.category || '-'),
E('td', { 'class': 'td' }, [
E('span', {
'style': 'padding: 2px 8px; border-radius: 10px; font-size: 0.85em;' +
(svc.status === 'running' ? 'background: #dcfce7; color: #166534;' :
'background: #fee2e2; color: #991b1b;')
}, svc.status || 'unknown')
]),
E('td', { 'class': 'td' },
urls.clearnet ?
E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet) :
'-'
),
E('td', { 'class': 'td' },
urls.onion ?
E('span', { 'style': 'font-size: 0.85em; word-break: break-all;' },
urls.onion.substring(0, 30) + '...') :
'-'
)
]);
})))
]);
}
// Customization info
var customSection = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Customization')),
E('p', {}, _('The landing page includes:')),
E('ul', {}, [
E('li', {}, _('Responsive grid layout with service cards')),
E('li', {}, _('QR codes for clearnet and onion URLs')),
E('li', {}, _('Copy-to-clipboard functionality')),
E('li', {}, _('Real-time service status')),
E('li', {}, _('Dark mode support')),
E('li', {}, _('Share buttons for social media'))
]),
E('p', { 'style': 'margin-top: 15px; color: #666;' },
_('To customize the appearance, edit the template at:') +
' /usr/sbin/secubox-landing-gen')
]);
mapEl.appendChild(statusSection);
mapEl.appendChild(actionsSection);
if (previewSection) mapEl.appendChild(previewSection);
mapEl.appendChild(customSection);
return mapEl;
});
},
handleRegenerate: function() {
ui.showModal(_('Regenerating'), [
E('p', { 'class': 'spinning' }, _('Regenerating landing page...'))
]);
return api.generateLandingPage().then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Landing page regenerated successfully')), 'info');
window.location.reload();
} else {
ui.addNotification(null, E('p', _('Failed to regenerate: ') + (result.error || '')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + err.message), 'error');
});
},
handlePreview: function() {
var self = this;
ui.showModal(_('Landing Page Preview'), [
E('div', { 'style': 'text-align: center;' }, [
E('iframe', {
'src': '/secubox-services.html',
'style': 'width: 100%; height: 500px; border: 1px solid #ddd; border-radius: 8px;'
})
]),
E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [
E('a', {
'class': 'cbi-button',
'href': '/secubox-services.html',
'target': '_blank'
}, _('Open in New Tab')),
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
])
], 'wide');
}
});

View File

@ -0,0 +1,512 @@
'use strict';
'require view';
'require dom';
'require poll';
'require ui';
'require service-registry/api as api';
// Icon mapping
var icons = {
'server': '🖥️', 'music': '🎵', 'shield': '🛡️', 'chart': '📊',
'settings': '⚙️', 'git': '📦', 'blog': '📝', 'arrow': '➡️',
'onion': '🧅', 'lock': '🔒', 'globe': '🌐', 'box': '📦',
'app': '📱', 'admin': '👤', 'stats': '📈', 'security': '🔐',
'feed': '📡', 'default': '🔗'
};
function getIcon(name) {
return icons[name] || icons['default'];
}
// Simple QR code generator
var QRCode = {
generateSVG: function(data, size) {
// Basic implementation - generates a simple visual representation
var matrix = this.generateMatrix(data);
var cellSize = size / matrix.length;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
for (var row = 0; row < matrix.length; row++) {
for (var col = 0; col < matrix[row].length; col++) {
if (matrix[row][col]) {
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
}
}
}
svg += '</svg>';
return svg;
},
generateMatrix: function(data) {
var size = Math.max(21, Math.min(41, Math.ceil(data.length / 2) + 17));
var matrix = [];
for (var i = 0; i < size; i++) {
matrix[i] = [];
for (var j = 0; j < size; j++) {
matrix[i][j] = 0;
}
}
// Add finder patterns
this.addFinderPattern(matrix, 0, 0);
this.addFinderPattern(matrix, size - 7, 0);
this.addFinderPattern(matrix, 0, size - 7);
// Timing
for (var i = 8; i < size - 8; i++) {
matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0;
}
// Data encoding (simplified)
var dataIndex = 0;
for (var col = size - 1; col > 0; col -= 2) {
if (col === 6) col--;
for (var row = 0; row < size; row++) {
for (var c = 0; c < 2; c++) {
var x = col - c;
if (matrix[row][x] === 0 && dataIndex < data.length * 8) {
var byteIndex = Math.floor(dataIndex / 8);
var bitIndex = dataIndex % 8;
var bit = byteIndex < data.length ?
(data.charCodeAt(byteIndex) >> (7 - bitIndex)) & 1 : 0;
matrix[row][x] = bit;
dataIndex++;
}
}
}
}
return matrix;
},
addFinderPattern: function(matrix, row, col) {
for (var r = 0; r < 7; r++) {
for (var c = 0; c < 7; c++) {
if ((r === 0 || r === 6 || c === 0 || c === 6) ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
matrix[row + r][col + c] = 1;
}
}
}
}
};
return view.extend({
title: _('Service Registry'),
pollInterval: 30,
load: function() {
return api.getDashboardData();
},
render: function(data) {
var self = this;
var services = data.services || [];
var providers = data.providers || {};
var categories = data.categories || [];
// Load CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('service-registry/registry.css');
document.head.appendChild(link);
return E('div', { 'class': 'sr-dashboard' }, [
this.renderHeader(),
this.renderStats(services, providers),
this.renderProviders(providers, data.haproxy, data.tor),
this.renderQuickPublish(categories),
this.renderServiceGrid(services, categories),
this.renderLandingLink(data.landing)
]);
},
renderHeader: function() {
return E('h2', { 'class': 'cbi-title' }, _('Service Registry'));
},
renderStats: function(services, providers) {
var published = services.filter(function(s) { return s.published; }).length;
var running = services.filter(function(s) { return s.status === 'running'; }).length;
var haproxyCount = providers.haproxy ? providers.haproxy.count : 0;
var torCount = providers.tor ? providers.tor.count : 0;
return E('div', { 'class': 'sr-stats' }, [
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(published)),
E('div', { 'class': 'sr-stat-label' }, _('Published'))
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(running)),
E('div', { 'class': 'sr-stat-label' }, _('Running'))
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(haproxyCount)),
E('div', { 'class': 'sr-stat-label' }, _('Domains'))
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(torCount)),
E('div', { 'class': 'sr-stat-label' }, _('Onion Sites'))
])
]);
},
renderProviders: function(providers, haproxy, tor) {
return E('div', { 'class': 'sr-providers' }, [
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot ' + (haproxy && haproxy.container_running ? 'running' : 'stopped') }),
E('span', {}, _('HAProxy'))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot ' + (tor && tor.running ? 'running' : 'stopped') }),
E('span', {}, _('Tor'))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot running' }),
E('span', {}, _('Direct: ') + String(providers.direct ? providers.direct.count : 0))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot running' }),
E('span', {}, _('LXC: ') + String(providers.lxc ? providers.lxc.count : 0))
])
]);
},
renderQuickPublish: function(categories) {
var self = this;
var categoryOptions = [E('option', { 'value': 'services' }, _('Services'))];
categories.forEach(function(cat) {
categoryOptions.push(E('option', { 'value': cat.id }, cat.name));
});
return E('div', { 'class': 'sr-quick-publish' }, [
E('h3', {}, _('Quick Publish')),
E('div', { 'class': 'sr-form' }, [
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Service Name')),
E('input', { 'type': 'text', 'id': 'pub-name', 'placeholder': 'e.g., Gitea' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Local Port')),
E('input', { 'type': 'number', 'id': 'pub-port', 'placeholder': '3000' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Domain (optional)')),
E('input', { 'type': 'text', 'id': 'pub-domain', 'placeholder': 'git.example.com' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Category')),
E('select', { 'id': 'pub-category' }, categoryOptions)
]),
E('div', { 'class': 'sr-checkbox-group' }, [
E('input', { 'type': 'checkbox', 'id': 'pub-tor' }),
E('label', { 'for': 'pub-tor' }, _('Enable Tor Hidden Service'))
]),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handlePublish')
}, _('Publish'))
])
]);
},
handlePublish: function() {
var self = this;
var name = document.getElementById('pub-name').value.trim();
var port = parseInt(document.getElementById('pub-port').value);
var domain = document.getElementById('pub-domain').value.trim();
var category = document.getElementById('pub-category').value;
var tor = document.getElementById('pub-tor').checked;
if (!name || !port) {
ui.addNotification(null, E('p', _('Name and port are required')), 'error');
return;
}
ui.showModal(_('Publishing Service'), [
E('p', { 'class': 'spinning' }, _('Creating service endpoints...'))
]);
return api.publishService(name, port, domain, tor, category, '').then(function(result) {
ui.hideModal();
if (result.success) {
self.showPublishedModal(result);
// Refresh view
return self.load().then(function(data) {
var container = document.querySelector('.sr-dashboard');
if (container) {
dom.content(container, self.render(data).childNodes);
}
});
} else {
ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + err.message), 'error');
});
},
showPublishedModal: function(result) {
var urls = result.urls || {};
var content = [
E('div', { 'class': 'sr-published-modal' }, [
E('h3', {}, _('Service Published Successfully!')),
E('p', {}, result.name)
])
];
var urlsDiv = E('div', { 'class': 'sr-urls' });
if (urls.local) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Local')),
E('input', { 'readonly': true, 'value': urls.local })
]));
}
if (urls.clearnet) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Clearnet')),
E('input', { 'readonly': true, 'value': urls.clearnet }),
E('div', { 'class': 'sr-qr-code' }),
]));
var qrDiv = urlsDiv.querySelector('.sr-qr-code:last-child');
if (qrDiv) {
qrDiv.innerHTML = QRCode.generateSVG(urls.clearnet, 120);
}
}
if (urls.onion) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Onion')),
E('input', { 'readonly': true, 'value': urls.onion }),
E('div', { 'class': 'sr-qr-code' })
]));
var qrDiv = urlsDiv.querySelectorAll('.sr-qr-code');
if (qrDiv.length > 0) {
qrDiv[qrDiv.length - 1].innerHTML = QRCode.generateSVG(urls.onion, 120);
}
}
content[0].appendChild(urlsDiv);
// Share buttons
var shareUrl = urls.clearnet || urls.onion || urls.local;
if (shareUrl) {
content[0].appendChild(E('div', { 'class': 'sr-share-buttons' }, [
E('a', {
'href': 'https://twitter.com/intent/tweet?url=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on X'
}, 'X'),
E('a', {
'href': 'https://t.me/share/url?url=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on Telegram'
}, 'TG'),
E('a', {
'href': 'https://wa.me/?text=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on WhatsApp'
}, 'WA')
]));
}
content.push(E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
]));
ui.showModal(_('Service Published'), content);
},
renderServiceGrid: function(services, categories) {
var self = this;
if (services.length === 0) {
return E('div', { 'class': 'sr-empty' }, [
E('h3', {}, _('No Services Found')),
E('p', {}, _('Use the quick publish form above to add your first service'))
]);
}
// Group by category
var grouped = {};
services.forEach(function(svc) {
var cat = svc.category || 'other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc);
});
var sections = [];
Object.keys(grouped).sort().forEach(function(cat) {
sections.push(E('div', { 'class': 'cbi-section' }, [
E('h3', {}, cat.charAt(0).toUpperCase() + cat.slice(1)),
E('div', { 'class': 'sr-grid' },
grouped[cat].map(function(svc) {
return self.renderServiceCard(svc);
})
)
]));
});
return E('div', {}, sections);
},
renderServiceCard: function(service) {
var self = this;
var urls = service.urls || {};
var urlRows = [];
if (urls.local) {
urlRows.push(this.renderUrlRow('Local', urls.local));
}
if (urls.clearnet) {
urlRows.push(this.renderUrlRow('Clearnet', urls.clearnet));
}
if (urls.onion) {
urlRows.push(this.renderUrlRow('Onion', urls.onion));
}
// QR codes for published services
var qrContainer = null;
if (service.published && (urls.clearnet || urls.onion)) {
var qrBoxes = [];
if (urls.clearnet) {
var qrBox = E('div', { 'class': 'sr-qr-box' }, [
E('div', { 'class': 'sr-qr-code' }),
E('div', { 'class': 'sr-qr-label' }, _('Clearnet'))
]);
qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.clearnet, 80);
qrBoxes.push(qrBox);
}
if (urls.onion) {
var qrBox = E('div', { 'class': 'sr-qr-box' }, [
E('div', { 'class': 'sr-qr-code' }),
E('div', { 'class': 'sr-qr-label' }, _('Onion'))
]);
qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.onion, 80);
qrBoxes.push(qrBox);
}
qrContainer = E('div', { 'class': 'sr-qr-container' }, qrBoxes);
}
// Action buttons
var actions = [];
if (service.published) {
actions.push(E('button', {
'class': 'cbi-button cbi-button-remove',
'click': ui.createHandlerFn(this, 'handleUnpublish', service.id)
}, _('Unpublish')));
} else {
actions.push(E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handleQuickPublishExisting', service)
}, _('Publish')));
}
return E('div', { 'class': 'sr-card' }, [
E('div', { 'class': 'sr-card-header' }, [
E('div', { 'class': 'sr-card-icon' }, getIcon(service.icon)),
E('div', { 'class': 'sr-card-title' }, service.name || service.id),
E('span', {
'class': 'sr-card-status sr-status-' + (service.status || 'stopped')
}, service.status || 'unknown')
]),
E('div', { 'class': 'sr-urls' }, urlRows),
qrContainer,
E('div', { 'class': 'sr-card-actions' }, actions)
]);
},
renderUrlRow: function(label, url) {
return E('div', { 'class': 'sr-url-row' }, [
E('span', { 'class': 'sr-url-label' }, label),
E('a', {
'class': 'sr-url-link',
'href': url,
'target': '_blank'
}, url),
E('button', {
'class': 'cbi-button sr-copy-btn',
'click': function() {
navigator.clipboard.writeText(url).then(function() {
ui.addNotification(null, E('p', _('URL copied to clipboard')), 'info');
});
}
}, _('Copy'))
]);
},
handleUnpublish: function(serviceId) {
var self = this;
ui.showModal(_('Unpublish Service'), [
E('p', {}, _('Are you sure you want to unpublish this service?')),
E('p', {}, _('This will remove HAProxy vhost and Tor hidden service if configured.')),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
ui.showModal(_('Unpublishing'), [
E('p', { 'class': 'spinning' }, _('Removing service...'))
]);
api.unpublishService(serviceId).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Service unpublished')), 'info');
return self.load().then(function(data) {
var container = document.querySelector('.sr-dashboard');
if (container) {
dom.content(container, self.render(data).childNodes);
}
});
} else {
ui.addNotification(null, E('p', _('Failed to unpublish')), 'error');
}
});
}
}, _('Unpublish'))
])
]);
},
handleQuickPublishExisting: function(service) {
document.getElementById('pub-name').value = service.name || '';
document.getElementById('pub-port').value = service.local_port || '';
document.getElementById('pub-name').focus();
},
renderLandingLink: function(landing) {
var path = landing && landing.path ? landing.path : '/www/secubox-services.html';
var exists = landing && landing.exists;
return E('div', { 'class': 'sr-landing-link' }, [
E('span', {}, _('Landing Page:')),
exists ?
E('a', { 'href': '/secubox-services.html', 'target': '_blank' }, path) :
E('span', {}, _('Not generated')),
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(this, 'handleRegenLanding')
}, _('Regenerate'))
]);
},
handleRegenLanding: function() {
var self = this;
ui.showModal(_('Generating'), [
E('p', { 'class': 'spinning' }, _('Regenerating landing page...'))
]);
api.generateLandingPage().then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Landing page regenerated')), 'info');
} else {
ui.addNotification(null, E('p', _('Failed: ') + (result.error || '')), 'error');
}
});
}
});

View File

@ -0,0 +1,283 @@
'use strict';
'require view';
'require dom';
'require ui';
'require form';
'require service-registry/api as api';
return view.extend({
title: _('Publish Service'),
load: function() {
return Promise.all([
api.listCategories(),
api.getUnpublishedServices()
]);
},
render: function(data) {
var self = this;
var categories = data[0].categories || [];
var unpublished = data[1] || [];
// Load CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('service-registry/registry.css');
document.head.appendChild(link);
var m, s, o;
m = new form.Map('service-registry', _('Publish New Service'),
_('Create a new published service with HAProxy reverse proxy and/or Tor hidden service.'));
s = m.section(form.NamedSection, '_new', 'service', _('Service Details'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Value, 'name', _('Service Name'));
o.placeholder = 'e.g., Gitea, Nextcloud';
o.rmempty = false;
o.validate = function(section_id, value) {
if (!value || value.trim() === '')
return _('Name is required');
if (!/^[a-zA-Z0-9\s_-]+$/.test(value))
return _('Name can only contain letters, numbers, spaces, dashes, and underscores');
return true;
};
o = s.option(form.Value, 'local_port', _('Local Port'),
_('The port where the service is listening locally'));
o.datatype = 'port';
o.placeholder = '3000';
o.rmempty = false;
o = s.option(form.ListValue, 'category', _('Category'));
o.value('services', _('Services'));
categories.forEach(function(cat) {
if (cat.id !== 'services') {
o.value(cat.id, cat.name);
}
});
o.default = 'services';
o = s.option(form.Value, 'icon', _('Icon'),
_('Icon name: server, music, shield, chart, git, blog, app, security, etc.'));
o.placeholder = 'server';
o.optional = true;
// HAProxy section
s = m.section(form.NamedSection, '_haproxy', 'haproxy', _('HAProxy (Clearnet)'),
_('Configure a public domain with automatic HTTPS certificate'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enable HAProxy Vhost'));
o.default = '0';
o = s.option(form.Value, 'domain', _('Domain'),
_('Public domain name (must point to this server)'));
o.placeholder = 'service.example.com';
o.depends('enabled', '1');
o.validate = function(section_id, value) {
if (!value) return true;
if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value))
return _('Invalid domain format');
return true;
};
o = s.option(form.Flag, 'ssl', _('Enable SSL/TLS'),
_('Request ACME certificate automatically'));
o.default = '1';
o.depends('enabled', '1');
o = s.option(form.Flag, 'ssl_redirect', _('Force HTTPS'),
_('Redirect HTTP to HTTPS'));
o.default = '1';
o.depends('ssl', '1');
// Tor section
s = m.section(form.NamedSection, '_tor', 'tor', _('Tor Hidden Service'),
_('Create a .onion address for anonymous access'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enable Tor Hidden Service'));
o.default = '0';
o = s.option(form.Value, 'virtual_port', _('Virtual Port'),
_('The port that will be exposed on the .onion address'));
o.datatype = 'port';
o.default = '80';
o.depends('enabled', '1');
return m.render().then(function(mapEl) {
// Add custom publish button
var publishBtn = E('button', {
'class': 'cbi-button cbi-button-apply',
'style': 'margin-top: 20px;',
'click': ui.createHandlerFn(self, 'handlePublish', m)
}, _('Publish Service'));
mapEl.appendChild(E('div', { 'class': 'cbi-page-actions' }, [publishBtn]));
// Add discoverable services section
if (unpublished.length > 0) {
mapEl.appendChild(E('div', { 'class': 'cbi-section', 'style': 'margin-top: 30px;' }, [
E('h3', {}, _('Discovered Services')),
E('p', {}, _('These services are running but not yet published:')),
E('div', { 'class': 'sr-grid' },
unpublished.slice(0, 10).map(function(svc) {
return self.renderDiscoveredCard(svc);
})
)
]));
}
return mapEl;
});
},
renderDiscoveredCard: function(service) {
var self = this;
return E('div', {
'class': 'sr-card',
'style': 'cursor: pointer;',
'click': function() {
self.prefillForm(service);
}
}, [
E('div', { 'class': 'sr-card-header' }, [
E('div', { 'class': 'sr-card-title' }, service.name || 'Port ' + service.local_port),
E('span', { 'class': 'sr-card-status sr-status-running' }, 'running')
]),
E('p', { 'style': 'font-size: 0.9em; color: #666;' },
_('Port: ') + service.local_port + ' | ' + _('Category: ') + (service.category || 'other'))
]);
},
prefillForm: function(service) {
var nameInput = document.querySelector('input[id*="name"]');
var portInput = document.querySelector('input[id*="local_port"]');
if (nameInput) nameInput.value = service.name || '';
if (portInput) portInput.value = service.local_port || '';
nameInput && nameInput.focus();
},
handlePublish: function(map) {
var self = this;
// Get form values
var nameEl = document.querySelector('input[id*="_new"][id*="name"]');
var portEl = document.querySelector('input[id*="_new"][id*="local_port"]');
var categoryEl = document.querySelector('select[id*="_new"][id*="category"]');
var iconEl = document.querySelector('input[id*="_new"][id*="icon"]');
var haproxyEnabledEl = document.querySelector('input[id*="_haproxy"][id*="enabled"]');
var domainEl = document.querySelector('input[id*="_haproxy"][id*="domain"]');
var torEnabledEl = document.querySelector('input[id*="_tor"][id*="enabled"]');
var name = nameEl ? nameEl.value.trim() : '';
var port = portEl ? parseInt(portEl.value) : 0;
var category = categoryEl ? categoryEl.value : 'services';
var icon = iconEl ? iconEl.value.trim() : '';
var haproxyEnabled = haproxyEnabledEl ? haproxyEnabledEl.checked : false;
var domain = domainEl ? domainEl.value.trim() : '';
var torEnabled = torEnabledEl ? torEnabledEl.checked : false;
// Validation
if (!name) {
ui.addNotification(null, E('p', _('Service name is required')), 'error');
return;
}
if (!port || port < 1 || port > 65535) {
ui.addNotification(null, E('p', _('Valid port number is required')), 'error');
return;
}
if (haproxyEnabled && !domain) {
ui.addNotification(null, E('p', _('Domain is required when HAProxy is enabled')), 'error');
return;
}
ui.showModal(_('Publishing Service'), [
E('p', { 'class': 'spinning' }, _('Creating service endpoints...')),
E('ul', {}, [
haproxyEnabled ? E('li', {}, _('Creating HAProxy vhost for ') + domain) : null,
haproxyEnabled ? E('li', {}, _('Requesting SSL certificate...')) : null,
torEnabled ? E('li', {}, _('Creating Tor hidden service...')) : null
].filter(Boolean))
]);
return api.publishService(
name,
port,
haproxyEnabled ? domain : '',
torEnabled,
category,
icon
).then(function(result) {
ui.hideModal();
if (result.success) {
self.showSuccessModal(result);
} else {
ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + err.message), 'error');
});
},
showSuccessModal: function(result) {
var urls = result.urls || {};
var content = [
E('div', { 'style': 'text-align: center; padding: 20px;' }, [
E('h3', { 'style': 'color: #22c55e;' }, _('Service Published!')),
E('p', {}, result.name)
])
];
var urlsDiv = E('div', { 'style': 'margin: 20px 0;' });
if (urls.local) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Local: ')),
E('code', {}, urls.local)
]));
}
if (urls.clearnet) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Clearnet: ')),
E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet)
]));
}
if (urls.onion) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Onion: ')),
E('code', { 'style': 'word-break: break-all;' }, urls.onion)
]));
}
content.push(urlsDiv);
content.push(E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': function() {
ui.hideModal();
window.location.href = L.url('admin/services/service-registry/overview');
}
}, _('Go to Overview')),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() {
ui.hideModal();
window.location.reload();
}
}, _('Publish Another'))
]));
ui.showModal(_('Success'), content);
}
});

View File

@ -0,0 +1,48 @@
config settings 'main'
option enabled '1'
option auto_tor '0'
option auto_haproxy '0'
option landing_path '/www/secubox-services.html'
option landing_auto_regen '1'
option default_category 'services'
config provider 'haproxy'
option enabled '1'
option type 'haproxy'
config provider 'tor'
option enabled '1'
option type 'tor'
config provider 'direct'
option enabled '1'
option type 'netstat'
config provider 'lxc'
option enabled '1'
option type 'lxc'
config category 'services'
option name 'Services'
option icon 'server'
option order '10'
config category 'media'
option name 'Media'
option icon 'music'
option order '20'
config category 'security'
option name 'Security'
option icon 'shield'
option order '30'
config category 'monitoring'
option name 'Monitoring'
option icon 'chart'
option order '40'
config category 'system'
option name 'System'
option icon 'settings'
option order '50'

View File

@ -0,0 +1,883 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# Service Registry RPCD backend
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
UCI_CONFIG="service-registry"
TOR_DATA="/var/lib/tor"
LAN_IP="192.168.255.1"
# Helper: Get UCI value
get_uci() {
local section="$1"
local option="$2"
local default="$3"
local value
value=$(uci -q get "$UCI_CONFIG.$section.$option")
echo "${value:-$default}"
}
# Helper: Get LAN IP address
get_lan_ip() {
local ip
ip=$(uci -q get network.lan.ipaddr)
[ -z "$ip" ] && ip=$(ip -4 addr show br-lan 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo "${ip:-$LAN_IP}"
}
# Helper: Check if port is listening
is_port_listening() {
local port="$1"
netstat -tln 2>/dev/null | grep -q ":${port} " && return 0
return 1
}
# Helper: Get process name for port
get_process_for_port() {
local port="$1"
netstat -tlnp 2>/dev/null | grep ":${port} " | awk '{print $7}' | cut -d'/' -f2 | head -1
}
# List all services aggregated from providers
method_list_services() {
local lan_ip
lan_ip=$(get_lan_ip)
json_init
json_add_array "services"
# Temporary file for service aggregation (avoid subshell issues)
local TMP_SERVICES="/tmp/sr_services_$$"
> "$TMP_SERVICES"
# 1. Get published services from UCI
config_load "$UCI_CONFIG"
config_foreach _add_published_service service "$lan_ip" "$TMP_SERVICES"
# 2. Get HAProxy vhosts
local haproxy_enabled
haproxy_enabled=$(get_uci haproxy enabled 1)
if [ "$haproxy_enabled" = "1" ]; then
_aggregate_haproxy_services "$lan_ip" "$TMP_SERVICES"
fi
# 3. Get Tor hidden services
local tor_enabled
tor_enabled=$(get_uci tor enabled 1)
if [ "$tor_enabled" = "1" ]; then
_aggregate_tor_services "$TMP_SERVICES"
fi
# 4. Get direct listening services (from luci.secubox)
local direct_enabled
direct_enabled=$(get_uci direct enabled 1)
if [ "$direct_enabled" = "1" ]; then
_aggregate_direct_services "$lan_ip" "$TMP_SERVICES"
fi
# 5. Get LXC container services
local lxc_enabled
lxc_enabled=$(get_uci lxc enabled 1)
if [ "$lxc_enabled" = "1" ]; then
_aggregate_lxc_services "$lan_ip" "$TMP_SERVICES"
fi
rm -f "$TMP_SERVICES"
json_close_array
# Provider status
json_add_object "providers"
json_add_object "haproxy"
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
json_add_string "status" "running"
else
json_add_string "status" "stopped"
fi
local haproxy_count
haproxy_count=$(uci -q show haproxy | grep -c "=vhost$")
json_add_int "count" "${haproxy_count:-0}"
json_close_object
json_add_object "tor"
if pgrep -f "/usr/sbin/tor" >/dev/null 2>&1; then
json_add_string "status" "running"
else
json_add_string "status" "stopped"
fi
local tor_count
tor_count=$(uci -q show tor-shield | grep -c "=hidden_service$")
json_add_int "count" "${tor_count:-0}"
json_close_object
json_add_object "direct"
local direct_count
direct_count=$(netstat -tln 2>/dev/null | grep -c LISTEN)
json_add_int "count" "${direct_count:-0}"
json_close_object
json_add_object "lxc"
local lxc_running
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
json_add_int "count" "${lxc_running:-0}"
json_close_object
json_close_object
json_dump
}
# Add published service from UCI
_add_published_service() {
local section="$1"
local lan_ip="$2"
local tmp_file="$3"
local name category icon local_port published
local haproxy_enabled haproxy_domain haproxy_ssl
local tor_enabled tor_onion tor_port
config_get name "$section" name "$section"
config_get category "$section" category "services"
config_get icon "$section" icon ""
config_get local_port "$section" local_port ""
config_get published "$section" published "0"
[ "$published" != "1" ] && return
[ -z "$local_port" ] && return
config_get haproxy_enabled "$section" haproxy_enabled "0"
config_get haproxy_domain "$section" haproxy_domain ""
config_get haproxy_ssl "$section" haproxy_ssl "0"
config_get tor_enabled "$section" tor_enabled "0"
config_get tor_onion "$section" tor_onion ""
config_get tor_port "$section" tor_port "80"
# Check if service is running
local status="stopped"
is_port_listening "$local_port" && status="running"
json_add_object
json_add_string "id" "$section"
json_add_string "name" "$name"
json_add_string "category" "$category"
json_add_string "icon" "$icon"
json_add_int "local_port" "$local_port"
json_add_string "status" "$status"
json_add_boolean "published" 1
# URLs
json_add_object "urls"
json_add_string "local" "http://${lan_ip}:${local_port}"
[ "$haproxy_enabled" = "1" ] && [ -n "$haproxy_domain" ] && {
if [ "$haproxy_ssl" = "1" ]; then
json_add_string "clearnet" "https://${haproxy_domain}"
else
json_add_string "clearnet" "http://${haproxy_domain}"
fi
}
[ "$tor_enabled" = "1" ] && [ -n "$tor_onion" ] && {
json_add_string "onion" "http://${tor_onion}:${tor_port}"
}
json_close_object
# HAProxy config
json_add_object "haproxy"
json_add_boolean "enabled" "$haproxy_enabled"
[ -n "$haproxy_domain" ] && json_add_string "domain" "$haproxy_domain"
json_add_boolean "ssl" "$haproxy_ssl"
json_close_object
# Tor config
json_add_object "tor"
json_add_boolean "enabled" "$tor_enabled"
[ -n "$tor_onion" ] && json_add_string "onion_address" "$tor_onion"
json_add_int "virtual_port" "$tor_port"
json_close_object
json_close_object
# Mark as processed
echo "$local_port" >> "$tmp_file"
}
# Aggregate HAProxy vhosts
_aggregate_haproxy_services() {
local lan_ip="$1"
local tmp_file="$2"
# Call HAProxy RPCD to get vhosts
local vhosts_json
vhosts_json=$(ubus call luci.haproxy list_vhosts 2>/dev/null)
[ -z "$vhosts_json" ] && return
echo "$vhosts_json" | jsonfilter -e '@.vhosts[*]' 2>/dev/null | while read -r line; do
local domain backend ssl
domain=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$line].domain" 2>/dev/null)
backend=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$line].backend" 2>/dev/null)
ssl=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$line].ssl" 2>/dev/null)
# Skip if domain empty
[ -z "$domain" ] && continue
# Check if already processed by local_port
# For HAProxy, we identify by domain
grep -q "^haproxy_${domain}$" "$tmp_file" 2>/dev/null && continue
json_add_object
json_add_string "id" "haproxy_$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g')"
json_add_string "name" "$domain"
json_add_string "category" "proxy"
json_add_string "icon" "arrow"
json_add_string "status" "running"
json_add_boolean "published" 1
json_add_string "source" "haproxy"
json_add_object "urls"
if [ "$ssl" = "true" ] || [ "$ssl" = "1" ]; then
json_add_string "clearnet" "https://${domain}"
else
json_add_string "clearnet" "http://${domain}"
fi
json_close_object
json_add_object "haproxy"
json_add_boolean "enabled" 1
json_add_string "domain" "$domain"
json_add_string "backend" "$backend"
json_add_boolean "ssl" "$ssl"
json_close_object
json_close_object
echo "haproxy_${domain}" >> "$tmp_file"
done
}
# Aggregate Tor hidden services
_aggregate_tor_services() {
local tmp_file="$1"
# Get hidden services from tor-shield config
config_load "tor-shield"
config_foreach _add_tor_hidden_service hidden_service "$tmp_file"
}
_add_tor_hidden_service() {
local section="$1"
local tmp_file="$2"
local enabled name local_port virtual_port
config_get enabled "$section" enabled "0"
[ "$enabled" != "1" ] && return
config_get name "$section" name "$section"
config_get local_port "$section" local_port "80"
config_get virtual_port "$section" virtual_port "80"
# Check if already processed
grep -q "^tor_${name}$" "$tmp_file" 2>/dev/null && continue
# Get onion address
local hostname_file="$TOR_DATA/hidden_service_${name}/hostname"
local onion_addr=""
[ -f "$hostname_file" ] && onion_addr=$(cat "$hostname_file")
json_add_object
json_add_string "id" "tor_${name}"
json_add_string "name" "${name} (Tor)"
json_add_string "category" "privacy"
json_add_string "icon" "onion"
json_add_int "local_port" "$local_port"
json_add_string "status" "running"
json_add_boolean "published" 1
json_add_string "source" "tor"
json_add_object "urls"
[ -n "$onion_addr" ] && json_add_string "onion" "http://${onion_addr}:${virtual_port}"
json_close_object
json_add_object "tor"
json_add_boolean "enabled" 1
json_add_string "onion_address" "$onion_addr"
json_add_int "virtual_port" "$virtual_port"
json_close_object
json_close_object
echo "tor_${name}" >> "$tmp_file"
}
# Aggregate direct listening services
_aggregate_direct_services() {
local lan_ip="$1"
local tmp_file="$2"
# Get services from luci.secubox get_services
local services_json
services_json=$(ubus call luci.secubox get_services 2>/dev/null)
[ -z "$services_json" ] && return
# Parse and add unpublished services
local count
count=$(echo "$services_json" | jsonfilter -e '@.services[*]' 2>/dev/null | wc -l)
local i=0
while [ $i -lt "$count" ]; do
local port name category icon external
port=$(echo "$services_json" | jsonfilter -e "@.services[$i].port" 2>/dev/null)
name=$(echo "$services_json" | jsonfilter -e "@.services[$i].name" 2>/dev/null)
category=$(echo "$services_json" | jsonfilter -e "@.services[$i].category" 2>/dev/null)
icon=$(echo "$services_json" | jsonfilter -e "@.services[$i].icon" 2>/dev/null)
external=$(echo "$services_json" | jsonfilter -e "@.services[$i].external" 2>/dev/null)
i=$((i + 1))
[ -z "$port" ] && continue
# Skip if already processed
grep -q "^${port}$" "$tmp_file" 2>/dev/null && continue
json_add_object
json_add_string "id" "direct_${port}"
json_add_string "name" "${name:-Port $port}"
json_add_string "category" "${category:-other}"
json_add_string "icon" "$icon"
json_add_int "local_port" "$port"
json_add_string "status" "running"
json_add_boolean "published" 0
json_add_string "source" "direct"
json_add_object "urls"
json_add_string "local" "http://${lan_ip}:${port}"
json_close_object
json_close_object
echo "$port" >> "$tmp_file"
done
}
# Aggregate LXC container services
_aggregate_lxc_services() {
local lan_ip="$1"
local tmp_file="$2"
# Get running containers
local containers
containers=$(lxc-ls --running 2>/dev/null)
[ -z "$containers" ] && return
for container in $containers; do
# Skip if already processed
grep -q "^lxc_${container}$" "$tmp_file" 2>/dev/null && continue
# Get container IP
local container_ip
container_ip=$(lxc-info -n "$container" -iH 2>/dev/null | head -1)
json_add_object
json_add_string "id" "lxc_${container}"
json_add_string "name" "$container"
json_add_string "category" "container"
json_add_string "icon" "box"
json_add_string "status" "running"
json_add_boolean "published" 0
json_add_string "source" "lxc"
json_add_object "urls"
[ -n "$container_ip" ] && json_add_string "local" "http://${container_ip}"
json_close_object
json_add_object "container"
json_add_string "name" "$container"
[ -n "$container_ip" ] && json_add_string "ip" "$container_ip"
json_close_object
json_close_object
echo "lxc_${container}" >> "$tmp_file"
done
}
# Get single service
method_get_service() {
local service_id
read -r input
json_load "$input"
json_get_var service_id service_id
json_init
if [ -z "$service_id" ]; then
json_add_boolean "success" 0
json_add_string "error" "service_id is required"
json_dump
return
fi
# Check if it's a published service
if uci -q get "$UCI_CONFIG.$service_id" >/dev/null 2>&1; then
local lan_ip
lan_ip=$(get_lan_ip)
json_add_boolean "success" 1
config_load "$UCI_CONFIG"
_add_published_service "$service_id" "$lan_ip" "/dev/null"
else
json_add_boolean "success" 0
json_add_string "error" "Service not found"
fi
json_dump
}
# Publish a service
method_publish_service() {
local name local_port domain tor_enabled category icon
read -r input
json_load "$input"
json_get_var name name
json_get_var local_port local_port
json_get_var domain domain ""
json_get_var tor_enabled tor_enabled "0"
json_get_var category category "services"
json_get_var icon icon ""
json_init
if [ -z "$name" ] || [ -z "$local_port" ]; then
json_add_boolean "success" 0
json_add_string "error" "name and local_port are required"
json_dump
return
fi
# Sanitize name for section ID
local section_id
section_id=$(echo "$name" | tr -cd 'a-zA-Z0-9_-' | tr '[:upper:]' '[:lower:]')
# Create UCI service entry
uci set "$UCI_CONFIG.$section_id=service"
uci set "$UCI_CONFIG.$section_id.name=$name"
uci set "$UCI_CONFIG.$section_id.local_port=$local_port"
uci set "$UCI_CONFIG.$section_id.category=$category"
[ -n "$icon" ] && uci set "$UCI_CONFIG.$section_id.icon=$icon"
uci set "$UCI_CONFIG.$section_id.published=1"
local lan_ip
lan_ip=$(get_lan_ip)
local urls_local="http://${lan_ip}:${local_port}"
local urls_clearnet=""
local urls_onion=""
# Create HAProxy vhost if domain specified
if [ -n "$domain" ]; then
# Create backend
ubus call luci.haproxy create_backend "{\"name\":\"$section_id\",\"mode\":\"http\"}" 2>/dev/null
# Create server pointing to local port
ubus call luci.haproxy create_server "{\"backend\":\"$section_id\",\"name\":\"local\",\"address\":\"127.0.0.1\",\"port\":$local_port}" 2>/dev/null
# Create vhost with SSL
ubus call luci.haproxy create_vhost "{\"domain\":\"$domain\",\"backend\":\"$section_id\",\"ssl\":1,\"ssl_redirect\":1,\"acme\":1,\"enabled\":1}" 2>/dev/null
uci set "$UCI_CONFIG.$section_id.haproxy_enabled=1"
uci set "$UCI_CONFIG.$section_id.haproxy_domain=$domain"
uci set "$UCI_CONFIG.$section_id.haproxy_ssl=1"
urls_clearnet="https://${domain}"
fi
# Create Tor hidden service if enabled
if [ "$tor_enabled" = "1" ]; then
ubus call luci.tor-shield add_hidden_service "{\"name\":\"$section_id\",\"local_port\":$local_port,\"virtual_port\":80}" 2>/dev/null
uci set "$UCI_CONFIG.$section_id.tor_enabled=1"
uci set "$UCI_CONFIG.$section_id.tor_port=80"
# Wait for onion address (max 5 seconds)
local wait_count=0
local onion_addr=""
while [ $wait_count -lt 5 ]; do
sleep 1
local hostname_file="$TOR_DATA/hidden_service_${section_id}/hostname"
if [ -f "$hostname_file" ]; then
onion_addr=$(cat "$hostname_file")
break
fi
wait_count=$((wait_count + 1))
done
if [ -n "$onion_addr" ]; then
uci set "$UCI_CONFIG.$section_id.tor_onion=$onion_addr"
urls_onion="http://${onion_addr}:80"
fi
fi
uci commit "$UCI_CONFIG"
# Regenerate landing page if auto-regen enabled
local auto_regen
auto_regen=$(get_uci main landing_auto_regen 1)
[ "$auto_regen" = "1" ] && /usr/sbin/secubox-landing-gen >/dev/null 2>&1 &
json_add_boolean "success" 1
json_add_string "id" "$section_id"
json_add_string "name" "$name"
json_add_object "urls"
json_add_string "local" "$urls_local"
[ -n "$urls_clearnet" ] && json_add_string "clearnet" "$urls_clearnet"
[ -n "$urls_onion" ] && json_add_string "onion" "$urls_onion"
json_close_object
json_dump
}
# Unpublish a service
method_unpublish_service() {
local service_id
read -r input
json_load "$input"
json_get_var service_id service_id
json_init
if [ -z "$service_id" ]; then
json_add_boolean "success" 0
json_add_string "error" "service_id is required"
json_dump
return
fi
# Check if service exists
if ! uci -q get "$UCI_CONFIG.$service_id" >/dev/null 2>&1; then
json_add_boolean "success" 0
json_add_string "error" "Service not found"
json_dump
return
fi
# Get service config
config_load "$UCI_CONFIG"
local haproxy_enabled haproxy_domain tor_enabled
config_get haproxy_enabled "$service_id" haproxy_enabled "0"
config_get haproxy_domain "$service_id" haproxy_domain ""
config_get tor_enabled "$service_id" tor_enabled "0"
# Remove HAProxy vhost
if [ "$haproxy_enabled" = "1" ] && [ -n "$haproxy_domain" ]; then
local vhost_id
vhost_id=$(echo "$haproxy_domain" | sed 's/[^a-zA-Z0-9]/_/g')
ubus call luci.haproxy delete_vhost "{\"id\":\"$vhost_id\"}" 2>/dev/null
ubus call luci.haproxy delete_backend "{\"id\":\"$service_id\"}" 2>/dev/null
fi
# Remove Tor hidden service
if [ "$tor_enabled" = "1" ]; then
ubus call luci.tor-shield remove_hidden_service "{\"name\":\"$service_id\"}" 2>/dev/null
fi
# Remove UCI entry
uci delete "$UCI_CONFIG.$service_id"
uci commit "$UCI_CONFIG"
# Regenerate landing page
local auto_regen
auto_regen=$(get_uci main landing_auto_regen 1)
[ "$auto_regen" = "1" ] && /usr/sbin/secubox-landing-gen >/dev/null 2>&1 &
json_add_boolean "success" 1
json_add_string "message" "Service unpublished"
json_dump
}
# Update service
method_update_service() {
local service_id name category icon
read -r input
json_load "$input"
json_get_var service_id service_id
json_get_var name name ""
json_get_var category category ""
json_get_var icon icon ""
json_init
if [ -z "$service_id" ]; then
json_add_boolean "success" 0
json_add_string "error" "service_id is required"
json_dump
return
fi
if ! uci -q get "$UCI_CONFIG.$service_id" >/dev/null 2>&1; then
json_add_boolean "success" 0
json_add_string "error" "Service not found"
json_dump
return
fi
[ -n "$name" ] && uci set "$UCI_CONFIG.$service_id.name=$name"
[ -n "$category" ] && uci set "$UCI_CONFIG.$service_id.category=$category"
[ -n "$icon" ] && uci set "$UCI_CONFIG.$service_id.icon=$icon"
uci commit "$UCI_CONFIG"
json_add_boolean "success" 1
json_dump
}
# Delete service
method_delete_service() {
method_unpublish_service
}
# Sync providers (refresh data)
method_sync_providers() {
json_init
json_add_boolean "success" 1
json_add_string "message" "Providers synced"
json_dump
}
# Generate landing page
method_generate_landing_page() {
json_init
local result
result=$(/usr/sbin/secubox-landing-gen 2>&1)
local rc=$?
if [ $rc -eq 0 ]; then
json_add_boolean "success" 1
json_add_string "message" "Landing page generated"
json_add_string "path" "$(get_uci main landing_path '/www/secubox-services.html')"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
}
# Get QR code data for a URL
method_get_qr_data() {
local service_id url_type
read -r input
json_load "$input"
json_get_var service_id service_id
json_get_var url_type url_type "local"
json_init
if [ -z "$service_id" ]; then
json_add_boolean "success" 0
json_add_string "error" "service_id is required"
json_dump
return
fi
# Get service URL
config_load "$UCI_CONFIG"
local lan_ip local_port haproxy_domain haproxy_ssl tor_onion
lan_ip=$(get_lan_ip)
config_get local_port "$service_id" local_port ""
config_get haproxy_domain "$service_id" haproxy_domain ""
config_get haproxy_ssl "$service_id" haproxy_ssl "0"
config_get tor_onion "$service_id" tor_onion ""
local url=""
case "$url_type" in
local)
[ -n "$local_port" ] && url="http://${lan_ip}:${local_port}"
;;
clearnet)
if [ -n "$haproxy_domain" ]; then
if [ "$haproxy_ssl" = "1" ]; then
url="https://${haproxy_domain}"
else
url="http://${haproxy_domain}"
fi
fi
;;
onion)
[ -n "$tor_onion" ] && url="http://${tor_onion}"
;;
esac
if [ -n "$url" ]; then
json_add_boolean "success" 1
json_add_string "url" "$url"
json_add_string "type" "$url_type"
else
json_add_boolean "success" 0
json_add_string "error" "URL not available for type: $url_type"
fi
json_dump
}
# List categories
method_list_categories() {
json_init
json_add_array "categories"
config_load "$UCI_CONFIG"
config_foreach _add_category category
json_close_array
json_dump
}
_add_category() {
local section="$1"
local name icon order
config_get name "$section" name "$section"
config_get icon "$section" icon ""
config_get order "$section" order "99"
json_add_object
json_add_string "id" "$section"
json_add_string "name" "$name"
json_add_string "icon" "$icon"
json_add_int "order" "$order"
json_close_object
}
# Get certificate status for service
method_get_certificate_status() {
local service_id
read -r input
json_load "$input"
json_get_var service_id service_id
json_init
if [ -z "$service_id" ]; then
json_add_boolean "success" 0
json_add_string "error" "service_id is required"
json_dump
return
fi
config_load "$UCI_CONFIG"
local haproxy_domain
config_get haproxy_domain "$service_id" haproxy_domain ""
if [ -z "$haproxy_domain" ]; then
json_add_boolean "success" 0
json_add_string "error" "No domain configured"
json_dump
return
fi
# Get certificate info from HAProxy
local cert_info
cert_info=$(ubus call luci.haproxy list_certificates 2>/dev/null)
json_add_boolean "success" 1
json_add_string "domain" "$haproxy_domain"
json_add_string "status" "unknown"
json_dump
}
# Get landing page config
method_get_landing_config() {
json_init
config_load "$UCI_CONFIG"
local landing_path auto_regen
config_get landing_path main landing_path "/www/secubox-services.html"
config_get auto_regen main landing_auto_regen "1"
json_add_string "path" "$landing_path"
json_add_boolean "auto_regen" "$auto_regen"
# Check if file exists
if [ -f "$landing_path" ]; then
json_add_boolean "exists" 1
local mtime
mtime=$(stat -c %Y "$landing_path" 2>/dev/null)
json_add_int "modified" "${mtime:-0}"
else
json_add_boolean "exists" 0
fi
json_dump
}
# Save landing page config
method_save_landing_config() {
local auto_regen
read -r input
json_load "$input"
json_get_var auto_regen auto_regen ""
json_init
[ -n "$auto_regen" ] && uci set "$UCI_CONFIG.main.landing_auto_regen=$auto_regen"
uci commit "$UCI_CONFIG"
json_add_boolean "success" 1
json_dump
}
# Main RPC interface
case "$1" in
list)
cat <<'EOF'
{
"list_services": {},
"get_service": { "service_id": "string" },
"publish_service": { "name": "string", "local_port": "integer", "domain": "string", "tor_enabled": "boolean", "category": "string", "icon": "string" },
"unpublish_service": { "service_id": "string" },
"update_service": { "service_id": "string", "name": "string", "category": "string", "icon": "string" },
"delete_service": { "service_id": "string" },
"sync_providers": {},
"generate_landing_page": {},
"get_qr_data": { "service_id": "string", "url_type": "string" },
"list_categories": {},
"get_certificate_status": { "service_id": "string" },
"get_landing_config": {},
"save_landing_config": { "auto_regen": "boolean" }
}
EOF
;;
call)
case "$2" in
list_services) method_list_services ;;
get_service) method_get_service ;;
publish_service) method_publish_service ;;
unpublish_service) method_unpublish_service ;;
update_service) method_update_service ;;
delete_service) method_delete_service ;;
sync_providers) method_sync_providers ;;
generate_landing_page) method_generate_landing_page ;;
get_qr_data) method_get_qr_data ;;
list_categories) method_list_categories ;;
get_certificate_status) method_get_certificate_status ;;
get_landing_config) method_get_landing_config ;;
save_landing_config) method_save_landing_config ;;
*)
json_init
json_add_boolean "error" 1
json_add_string "message" "Unknown method: $2"
json_dump
;;
esac
;;
esac

View File

@ -0,0 +1,486 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# SecuBox Landing Page Generator
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
UCI_CONFIG="service-registry"
OUTPUT_PATH="/www/secubox-services.html"
# Get output path from config
config_load "$UCI_CONFIG"
config_get OUTPUT_PATH main landing_path "$OUTPUT_PATH"
# Get services JSON
SERVICES_JSON=$(ubus call luci.service-registry list_services 2>/dev/null)
if [ -z "$SERVICES_JSON" ]; then
echo "Error: Could not fetch services"
exit 1
fi
# Get hostname
HOSTNAME=$(uci -q get system.@system[0].hostname || echo "SecuBox")
# Generate HTML
cat > "$OUTPUT_PATH" <<'HTMLHEAD'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecuBox Services</title>
<style>
:root {
--primary: #0ff;
--primary-dim: #099;
--bg: #1a1a2e;
--card: #16213e;
--card-hover: #1c2a4a;
--text: #e4e4e7;
--text-dim: #a1a1aa;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
}
.header h1 {
color: var(--primary);
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
.header p {
color: var(--text-dim);
font-size: 1.1em;
}
.stats {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 20px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--primary);
}
.stat-label {
font-size: 0.85em;
color: var(--text-dim);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
padding: 10px;
}
.card {
background: var(--card);
border-radius: 12px;
padding: 20px;
border: 1px solid #333;
transition: all 0.2s ease;
}
.card:hover {
background: var(--card-hover);
border-color: var(--primary-dim);
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.card-icon {
font-size: 1.8em;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 255, 255, 0.1);
border-radius: 10px;
}
.card-title {
font-size: 1.15em;
font-weight: 600;
flex: 1;
}
.card-status {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75em;
font-weight: 500;
}
.status-running { background: var(--success); color: #000; }
.status-stopped { background: var(--error); color: #fff; }
.urls {
margin: 15px 0;
}
.url-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
padding: 10px;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
}
.url-label {
min-width: 70px;
font-size: 0.75em;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.url-link {
color: var(--primary);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9em;
}
.url-link:hover {
text-decoration: underline;
}
.copy-btn {
background: transparent;
border: 1px solid #444;
color: var(--text-dim);
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s;
}
.copy-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.copy-btn.copied {
border-color: var(--success);
color: var(--success);
}
.qr-container {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #333;
}
.qr-box {
text-align: center;
}
.qr-code {
background: #fff;
padding: 8px;
border-radius: 8px;
display: inline-block;
}
.qr-code svg {
display: block;
}
.qr-label {
font-size: 0.7em;
color: var(--text-dim);
margin-top: 6px;
text-transform: uppercase;
}
.footer {
text-align: center;
color: var(--text-dim);
font-size: 0.85em;
margin-top: 40px;
padding: 20px;
}
.footer a {
color: var(--primary);
text-decoration: none;
}
.category-section {
margin-bottom: 40px;
}
.category-title {
font-size: 1.3em;
color: var(--text-dim);
margin-bottom: 15px;
padding-left: 10px;
border-left: 3px solid var(--primary);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state h2 {
margin-bottom: 10px;
}
@media (max-width: 600px) {
.header h1 { font-size: 1.8em; }
.grid { grid-template-columns: 1fr; }
.stats { gap: 15px; }
.qr-container { flex-direction: column; align-items: center; }
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>SecuBox Services</h1>
<p>Published endpoints and access links</p>
<div class="stats" id="stats"></div>
</header>
<main id="services-container"></main>
<footer class="footer">
<p>Generated: <span id="timestamp"></span></p>
<p>Powered by <a href="#">SecuBox</a></p>
</footer>
</div>
<script>
// QR Code generator (minimal SVG implementation)
var QRCode = (function() {
function generateMatrix(data) {
// Simple QR matrix generator - uses a basic encoding
// For production, use a proper library
var size = Math.max(21, Math.ceil(data.length / 2) + 17);
size = Math.min(size, 41);
var matrix = [];
for (var i = 0; i < size; i++) {
matrix[i] = [];
for (var j = 0; j < size; j++) {
matrix[i][j] = 0;
}
}
// Add finder patterns
addFinderPattern(matrix, 0, 0);
addFinderPattern(matrix, size - 7, 0);
addFinderPattern(matrix, 0, size - 7);
// Add timing patterns
for (var i = 8; i < size - 8; i++) {
matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0;
}
// Encode data (simplified)
var dataIndex = 0;
for (var col = size - 1; col > 0; col -= 2) {
if (col === 6) col--;
for (var row = 0; row < size; row++) {
for (var c = 0; c < 2; c++) {
var x = col - c;
if (matrix[row][x] === 0 && dataIndex < data.length * 8) {
var byteIndex = Math.floor(dataIndex / 8);
var bitIndex = dataIndex % 8;
var bit = byteIndex < data.length ?
(data.charCodeAt(byteIndex) >> (7 - bitIndex)) & 1 : 0;
matrix[row][x] = bit;
dataIndex++;
}
}
}
}
return matrix;
}
function addFinderPattern(matrix, row, col) {
for (var r = 0; r < 7; r++) {
for (var c = 0; c < 7; c++) {
if ((r === 0 || r === 6 || c === 0 || c === 6) ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
matrix[row + r][col + c] = 1;
}
}
}
}
return {
generateSVG: function(data, size) {
var matrix = generateMatrix(data);
var cellSize = size / matrix.length;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
for (var row = 0; row < matrix.length; row++) {
for (var col = 0; col < matrix[row].length; col++) {
if (matrix[row][col]) {
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
}
}
}
svg += '</svg>';
return svg;
}
};
})();
// Service data
var servicesData = SERVICES_JSON_PLACEHOLDER;
function getIcon(iconName) {
var icons = {
'server': '🖥️', 'music': '🎵', 'shield': '🛡️', 'chart': '📊',
'settings': '⚙️', 'git': '📦', 'blog': '📝', 'arrow': '➡️',
'onion': '🧅', 'lock': '🔒', 'globe': '🌐', 'box': '📦',
'app': '📱', 'admin': '👤', 'stats': '📈', 'security': '🔐',
'feed': '📡', 'default': '🔗'
};
return icons[iconName] || icons['default'];
}
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
});
}
function renderServices() {
var container = document.getElementById('services-container');
var services = servicesData.services || [];
var published = services.filter(function(s) { return s.published; });
// Update stats
var statsEl = document.getElementById('stats');
var haproxy = servicesData.providers?.haproxy || {};
var tor = servicesData.providers?.tor || {};
statsEl.innerHTML =
'<div class="stat"><div class="stat-value">' + published.length + '</div><div class="stat-label">Services</div></div>' +
'<div class="stat"><div class="stat-value">' + (haproxy.count || 0) + '</div><div class="stat-label">Domains</div></div>' +
'<div class="stat"><div class="stat-value">' + (tor.count || 0) + '</div><div class="stat-label">Onion Sites</div></div>';
if (published.length === 0) {
container.innerHTML = '<div class="empty-state"><h2>No Published Services</h2><p>Publish services using the Service Registry dashboard</p></div>';
return;
}
// Group by category
var categories = {};
published.forEach(function(service) {
var cat = service.category || 'other';
if (!categories[cat]) categories[cat] = [];
categories[cat].push(service);
});
var html = '';
Object.keys(categories).sort().forEach(function(cat) {
html += '<section class="category-section">';
html += '<h2 class="category-title">' + cat.charAt(0).toUpperCase() + cat.slice(1) + '</h2>';
html += '<div class="grid">';
categories[cat].forEach(function(service) {
html += renderServiceCard(service);
});
html += '</div></section>';
});
container.innerHTML = html;
// Add event listeners for copy buttons
document.querySelectorAll('.copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
copyToClipboard(this.dataset.url, this);
});
});
}
function renderServiceCard(service) {
var urls = service.urls || {};
var html = '<div class="card">';
// Header
html += '<div class="card-header">';
html += '<div class="card-icon">' + getIcon(service.icon) + '</div>';
html += '<div class="card-title">' + escapeHtml(service.name) + '</div>';
html += '<span class="card-status status-' + (service.status || 'stopped') + '">' +
(service.status || 'unknown') + '</span>';
html += '</div>';
// URLs
html += '<div class="urls">';
if (urls.local) {
html += renderUrlRow('Local', urls.local);
}
if (urls.clearnet) {
html += renderUrlRow('Clearnet', urls.clearnet);
}
if (urls.onion) {
html += renderUrlRow('Onion', urls.onion);
}
html += '</div>';
// QR Codes
var qrUrls = [];
if (urls.clearnet) qrUrls.push({ label: 'Clearnet', url: urls.clearnet });
if (urls.onion) qrUrls.push({ label: 'Onion', url: urls.onion });
if (qrUrls.length > 0) {
html += '<div class="qr-container">';
qrUrls.forEach(function(qr) {
html += '<div class="qr-box">';
html += '<div class="qr-code">' + QRCode.generateSVG(qr.url, 100) + '</div>';
html += '<div class="qr-label">' + qr.label + '</div>';
html += '</div>';
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderUrlRow(label, url) {
return '<div class="url-row">' +
'<span class="url-label">' + label + '</span>' +
'<a href="' + escapeHtml(url) + '" class="url-link" target="_blank">' + escapeHtml(url) + '</a>' +
'<button class="copy-btn" data-url="' + escapeHtml(url) + '">Copy</button>' +
'</div>';
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initialize
document.getElementById('timestamp').textContent = new Date().toLocaleString();
renderServices();
</script>
</body>
</html>
HTMLHEAD
# Replace placeholder with actual JSON using awk (more reliable than sed for JSON)
TMP_OUTPUT="${OUTPUT_PATH}.tmp"
awk -v json="$SERVICES_JSON" '{gsub(/SERVICES_JSON_PLACEHOLDER/, json); print}' "$OUTPUT_PATH" > "$TMP_OUTPUT"
mv "$TMP_OUTPUT" "$OUTPUT_PATH"
echo "Landing page generated: $OUTPUT_PATH"

View File

@ -0,0 +1,277 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# SecuBox Service Registry CLI
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
UCI_CONFIG="service-registry"
usage() {
cat <<EOF
SecuBox Service Registry - Unified service management
Usage: secubox-registry <command> [options]
Commands:
list List all services
show <service_id> Show service details
publish <name> <port> [opts] Publish a new service
unpublish <service_id> Unpublish a service
landing regen Regenerate landing page
categories List categories
Publish options:
--domain <domain> Create HAProxy vhost with ACME cert
--tor Enable Tor hidden service
--category <cat> Service category (default: services)
--icon <icon> Service icon
Examples:
secubox-registry list
secubox-registry publish "Gitea" 3000 --domain git.example.com --tor
secubox-registry unpublish gitea
secubox-registry landing regen
EOF
exit 1
}
# List services
cmd_list() {
local json_output
json_output=$(ubus call luci.service-registry list_services 2>/dev/null)
if [ -z "$json_output" ]; then
echo "Error: Could not fetch services"
exit 1
fi
echo "SecuBox Services"
echo "================"
echo
# Parse and display services
local count
count=$(echo "$json_output" | jsonfilter -e '@.services[*]' 2>/dev/null | wc -l)
if [ "$count" -eq 0 ]; then
echo "No services found"
return
fi
printf "%-20s %-10s %-8s %-30s %-15s\n" "NAME" "PORT" "STATUS" "CLEARNET" "ONION"
printf "%-20s %-10s %-8s %-30s %-15s\n" "----" "----" "------" "--------" "-----"
local i=0
while [ $i -lt "$count" ]; do
local name port status clearnet onion
name=$(echo "$json_output" | jsonfilter -e "@.services[$i].name" 2>/dev/null)
port=$(echo "$json_output" | jsonfilter -e "@.services[$i].local_port" 2>/dev/null)
status=$(echo "$json_output" | jsonfilter -e "@.services[$i].status" 2>/dev/null)
clearnet=$(echo "$json_output" | jsonfilter -e "@.services[$i].urls.clearnet" 2>/dev/null)
onion=$(echo "$json_output" | jsonfilter -e "@.services[$i].urls.onion" 2>/dev/null)
[ -z "$clearnet" ] && clearnet="-"
[ -z "$onion" ] && onion="-"
[ -z "$port" ] && port="-"
# Truncate long strings
[ ${#clearnet} -gt 30 ] && clearnet="${clearnet:0:27}..."
[ ${#onion} -gt 15 ] && onion="${onion:0:12}..."
printf "%-20s %-10s %-8s %-30s %-15s\n" "$name" "$port" "$status" "$clearnet" "$onion"
i=$((i + 1))
done
echo
echo "Providers:"
local haproxy_status haproxy_count tor_status tor_count
haproxy_status=$(echo "$json_output" | jsonfilter -e "@.providers.haproxy.status" 2>/dev/null)
haproxy_count=$(echo "$json_output" | jsonfilter -e "@.providers.haproxy.count" 2>/dev/null)
tor_status=$(echo "$json_output" | jsonfilter -e "@.providers.tor.status" 2>/dev/null)
tor_count=$(echo "$json_output" | jsonfilter -e "@.providers.tor.count" 2>/dev/null)
echo " HAProxy: $haproxy_status ($haproxy_count vhosts)"
echo " Tor: $tor_status ($tor_count hidden services)"
}
# Show service details
cmd_show() {
local service_id="$1"
if [ -z "$service_id" ]; then
echo "Error: service_id required"
usage
fi
local json_output
json_output=$(ubus call luci.service-registry get_service "{\"service_id\":\"$service_id\"}" 2>/dev/null)
if echo "$json_output" | grep -q '"success":false'; then
echo "Error: Service not found"
exit 1
fi
echo "$json_output" | jsonfilter -e '@' 2>/dev/null
}
# Publish service
cmd_publish() {
local name="$1"
local port="$2"
shift 2
if [ -z "$name" ] || [ -z "$port" ]; then
echo "Error: name and port required"
usage
fi
local domain=""
local tor_enabled="0"
local category="services"
local icon=""
while [ $# -gt 0 ]; do
case "$1" in
--domain)
domain="$2"
shift 2
;;
--tor)
tor_enabled="1"
shift
;;
--category)
category="$2"
shift 2
;;
--icon)
icon="$2"
shift 2
;;
*)
echo "Unknown option: $1"
usage
;;
esac
done
local params="{\"name\":\"$name\",\"local_port\":$port,\"tor_enabled\":$tor_enabled,\"category\":\"$category\""
[ -n "$domain" ] && params="$params,\"domain\":\"$domain\""
[ -n "$icon" ] && params="$params,\"icon\":\"$icon\""
params="$params}"
echo "Publishing service: $name on port $port"
[ -n "$domain" ] && echo " Domain: $domain (HAProxy + ACME)"
[ "$tor_enabled" = "1" ] && echo " Tor hidden service: enabled"
local result
result=$(ubus call luci.service-registry publish_service "$params" 2>/dev/null)
if echo "$result" | grep -q '"success":true'; then
echo
echo "Service published successfully!"
echo
echo "URLs:"
local url_local url_clearnet url_onion
url_local=$(echo "$result" | jsonfilter -e '@.urls.local' 2>/dev/null)
url_clearnet=$(echo "$result" | jsonfilter -e '@.urls.clearnet' 2>/dev/null)
url_onion=$(echo "$result" | jsonfilter -e '@.urls.onion' 2>/dev/null)
[ -n "$url_local" ] && echo " Local: $url_local"
[ -n "$url_clearnet" ] && echo " Clearnet: $url_clearnet"
[ -n "$url_onion" ] && echo " Onion: $url_onion"
else
echo "Error: Failed to publish service"
echo "$result"
exit 1
fi
}
# Unpublish service
cmd_unpublish() {
local service_id="$1"
if [ -z "$service_id" ]; then
echo "Error: service_id required"
usage
fi
echo "Unpublishing service: $service_id"
local result
result=$(ubus call luci.service-registry unpublish_service "{\"service_id\":\"$service_id\"}" 2>/dev/null)
if echo "$result" | grep -q '"success":true'; then
echo "Service unpublished successfully"
else
echo "Error: Failed to unpublish service"
echo "$result"
exit 1
fi
}
# Landing page commands
cmd_landing() {
local subcmd="$1"
case "$subcmd" in
regen|regenerate)
echo "Regenerating landing page..."
/usr/sbin/secubox-landing-gen
echo "Done"
;;
*)
echo "Usage: secubox-registry landing regen"
exit 1
;;
esac
}
# List categories
cmd_categories() {
local json_output
json_output=$(ubus call luci.service-registry list_categories 2>/dev/null)
echo "Categories:"
echo "$json_output" | jsonfilter -e '@.categories[*].id' 2>/dev/null | while read id; do
local name icon
name=$(echo "$json_output" | jsonfilter -e "@.categories[@.id='$id'].name" 2>/dev/null)
icon=$(echo "$json_output" | jsonfilter -e "@.categories[@.id='$id'].icon" 2>/dev/null)
echo " $id: $name ($icon)"
done
}
# Main
case "$1" in
list)
cmd_list
;;
show)
shift
cmd_show "$@"
;;
publish)
shift
cmd_publish "$@"
;;
unpublish)
shift
cmd_unpublish "$@"
;;
landing)
shift
cmd_landing "$@"
;;
categories)
cmd_categories
;;
-h|--help|help)
usage
;;
*)
usage
;;
esac

View File

@ -0,0 +1,37 @@
{
"admin/services/service-registry": {
"title": "Service Registry",
"order": 15,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-service-registry"],
"uci": { "service-registry": true }
}
},
"admin/services/service-registry/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "service-registry/overview"
}
},
"admin/services/service-registry/publish": {
"title": "Publish",
"order": 20,
"action": {
"type": "view",
"path": "service-registry/publish"
}
},
"admin/services/service-registry/landing": {
"title": "Landing Page",
"order": 30,
"action": {
"type": "view",
"path": "service-registry/landing"
}
}
}

View File

@ -0,0 +1,60 @@
{
"luci-app-service-registry": {
"description": "Grant access to Service Registry",
"read": {
"ubus": {
"luci.service-registry": [
"list_services",
"get_service",
"list_categories",
"get_qr_data",
"get_certificate_status",
"get_landing_config"
],
"luci.haproxy": [
"status",
"list_vhosts",
"list_backends",
"list_certificates"
],
"luci.tor-shield": [
"status",
"hidden_services"
],
"luci.secubox": [
"get_services"
]
},
"uci": ["service-registry", "haproxy", "tor-shield"]
},
"write": {
"ubus": {
"luci.service-registry": [
"publish_service",
"unpublish_service",
"sync_providers",
"generate_landing_page",
"update_service",
"delete_service",
"save_landing_config"
],
"luci.haproxy": [
"create_vhost",
"create_backend",
"create_server",
"request_certificate",
"delete_vhost",
"delete_backend"
],
"luci.tor-shield": [
"add_hidden_service",
"remove_hidden_service"
]
},
"uci": ["service-registry"],
"file": {
"/www/secubox-services.html": ["write"]
}
}
}
}

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecuBox Services</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #1a1a2e;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
text-align: center;
}
.container {
padding: 40px;
}
h1 {
color: #0ff;
margin-bottom: 20px;
}
p {
color: #a1a1aa;
margin-bottom: 30px;
}
a {
color: #0ff;
text-decoration: none;
padding: 12px 24px;
border: 1px solid #0ff;
border-radius: 8px;
display: inline-block;
transition: all 0.2s;
}
a:hover {
background: #0ff;
color: #1a1a2e;
}
</style>
</head>
<body>
<div class="container">
<h1>SecuBox Services</h1>
<p>Landing page not yet generated.</p>
<p>Use the Service Registry dashboard to publish services and generate this page.</p>
<a href="/cgi-bin/luci/admin/services/service-registry/overview">Go to Dashboard</a>
</div>
</body>
</html>