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:
parent
0f6953ad06
commit
ccba39da62
52
package/secubox/luci-app-service-registry/Makefile
Normal file
52
package/secubox/luci-app-service-registry/Makefile
Normal 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))
|
||||
@ -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', '');
|
||||
}
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user