diff --git a/package/secubox/luci-app-service-registry/Makefile b/package/secubox/luci-app-service-registry/Makefile new file mode 100644 index 00000000..52a358a8 --- /dev/null +++ b/package/secubox/luci-app-service-registry/Makefile @@ -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 +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)) diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js new file mode 100644 index 00000000..abeac027 --- /dev/null +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js @@ -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', ''); + } +}); diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/registry.css b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/registry.css new file mode 100644 index 00000000..42d5dcee --- /dev/null +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/registry.css @@ -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; + } +} diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/landing.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/landing.js new file mode 100644 index 00000000..194f2892 --- /dev/null +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/landing.js @@ -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'); + } +}); diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js new file mode 100644 index 00000000..bd282f0e --- /dev/null +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js @@ -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 += ''; + for (var row = 0; row < matrix.length; row++) { + for (var col = 0; col < matrix[row].length; col++) { + if (matrix[row][col]) { + 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'); + } + }); + } +}); diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js new file mode 100644 index 00000000..83f0aec2 --- /dev/null +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/publish.js @@ -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); + } +}); diff --git a/package/secubox/luci-app-service-registry/root/etc/config/service-registry b/package/secubox/luci-app-service-registry/root/etc/config/service-registry new file mode 100644 index 00000000..c3fd6f59 --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/etc/config/service-registry @@ -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' diff --git a/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry b/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry new file mode 100644 index 00000000..58d9e8f3 --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry @@ -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 diff --git a/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen new file mode 100644 index 00000000..1786875e --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-landing-gen @@ -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' + + + + + + SecuBox Services + + + +
+
+

SecuBox Services

+

Published endpoints and access links

+
+
+
+
+

Generated:

+

Powered by SecuBox

+
+
+ + + + +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" diff --git a/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-registry b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-registry new file mode 100644 index 00000000..0a49ae41 --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/usr/sbin/secubox-registry @@ -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 < [options] + +Commands: + list List all services + show Show service details + publish [opts] Publish a new service + unpublish Unpublish a service + landing regen Regenerate landing page + categories List categories + +Publish options: + --domain Create HAProxy vhost with ACME cert + --tor Enable Tor hidden service + --category Service category (default: services) + --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 diff --git a/package/secubox/luci-app-service-registry/root/usr/share/luci/menu.d/luci-app-service-registry.json b/package/secubox/luci-app-service-registry/root/usr/share/luci/menu.d/luci-app-service-registry.json new file mode 100644 index 00000000..c0bc46e8 --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/usr/share/luci/menu.d/luci-app-service-registry.json @@ -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" + } + } +} diff --git a/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json b/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json new file mode 100644 index 00000000..a358fe13 --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json @@ -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"] + } + } + } +} diff --git a/package/secubox/luci-app-service-registry/root/www/secubox-services.html b/package/secubox/luci-app-service-registry/root/www/secubox-services.html new file mode 100644 index 00000000..927ac87a --- /dev/null +++ b/package/secubox/luci-app-service-registry/root/www/secubox-services.html @@ -0,0 +1,53 @@ + + + + + + SecuBox Services + + + +
+

SecuBox Services

+

Landing page not yet generated.

+

Use the Service Registry dashboard to publish services and generate this page.

+ Go to Dashboard +
+ +