secubox-openwrt/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/overview.js
CyberMind-FR 26daa57a4b fix(multi): HAProxy duplicate server, Streamlit headless, dashboard optimization
Fixes:
- HAProxy: Prevent duplicate server names when both inline and separate
  server UCI sections exist for same backend
- Streamlit: Force --server.headless=true in start script (required for server)
- Dashboard: Optimize get_dashboard_data RPC call (6.56s → 0.09s) by using
  fast catalog counting instead of slow appstore list command
- Exposure: Add themed dashboard with SecuBox styling
- ACL: Add missing RPCD permissions for various LuCI apps

Version bumps:
- luci-app-exposure: 1.0.0-r3
- secubox-core: 0.10.0-r5
- secubox-app-haproxy: 1.0.0-r18
- secubox-app-streamlit: 1.0.0-r2
- Portal: v0.15.51

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:04:02 +01:00

1092 lines
50 KiB
JavaScript

'use strict';
'require view';
'require dom';
'require ui';
'require poll';
'require exposure/api as api';
/**
* SecuBox Service Exposure Manager - Overview Dashboard
* Manages Tor Hidden Services and HAProxy SSL backends
* Progressive loading with debug console output
* Copyright (C) 2025 CyberMind.fr
*/
return view.extend({
title: _('Service Exposure Manager'),
data: null,
pollRegistered: false,
loadStartTime: null,
DEBUG: true,
// === Debug Logging with timestamps ===
log: function(step, message, data) {
if (!this.DEBUG) return;
var elapsed = this.loadStartTime ? (Date.now() - this.loadStartTime) + 'ms' : '0ms';
var prefix = '%c[Exposure ' + elapsed + '] %c' + step + '%c';
if (data !== undefined) {
console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message, data);
} else {
console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message);
}
},
load: function() {
var self = this;
this.loadStartTime = Date.now();
this.log('INIT', 'Starting dashboard load');
// Load CSS first
var cssLink = document.querySelector('link[href*="exposure/dashboard.css"]');
if (!cssLink) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('exposure/dashboard.css');
document.head.appendChild(link);
this.log('CSS', 'Stylesheet injected');
}
// Progressive data loading with individual error handling
return this.loadDataProgressively();
},
loadDataProgressively: function() {
var self = this;
// Initialize result container
var result = {
status: { services: {}, tor: {}, ssl: {} },
scan: { services: [] },
conflicts: { conflicts: [] },
tor: { services: [] },
ssl: { backends: [] }
};
// Create promises with individual timing and error handling
// Priority: status > ssl > tor > scan > conflicts
self.log('FETCH', '1/5 Status (priority: critical)');
var p1 = api.status().then(function(data) {
result.status = data || result.status;
self.log('STATUS', 'Complete', {
total: (result.status.services || {}).total || 0,
external: (result.status.services || {}).external || 0,
tor: (result.status.tor || {}).count || 0,
ssl: (result.status.ssl || {}).count || 0
});
return data;
}).catch(function(err) {
self.log('ERROR', 'Status fetch failed: ' + err.message);
return result.status;
});
self.log('FETCH', '2/5 SSL Backends (priority: high)');
var p2 = api.sslList().then(function(data) {
result.ssl = data || result.ssl;
var backends = result.ssl.backends || [];
self.log('SSL', 'Complete', {
count: backends.length,
domains: backends.map(function(b) { return b.domain; })
});
return data;
}).catch(function(err) {
self.log('ERROR', 'SSL list failed: ' + err.message);
return result.ssl;
});
self.log('FETCH', '3/5 Tor Services (priority: high)');
var p3 = api.torList().then(function(data) {
result.tor = data || result.tor;
var services = result.tor.services || [];
self.log('TOR', 'Complete', {
count: services.length,
onions: services.map(function(s) { return s.service + ': ' + (s.onion || '').substring(0, 16) + '...'; })
});
return data;
}).catch(function(err) {
self.log('ERROR', 'Tor list failed: ' + err.message);
return result.tor;
});
self.log('FETCH', '4/5 Service Scan (priority: medium)');
var p4 = api.scan().then(function(data) {
result.scan = data || result.scan;
var services = Array.isArray(data) ? data : (data.services || []);
var external = services.filter(function(s) { return s.external; });
self.log('SCAN', 'Complete', {
total: services.length,
external: external.length,
processes: external.slice(0, 5).map(function(s) { return s.name || s.process; })
});
return data;
}).catch(function(err) {
self.log('ERROR', 'Scan failed: ' + err.message);
return result.scan;
});
self.log('FETCH', '5/5 Conflicts (priority: low)');
var p5 = api.conflicts().then(function(data) {
result.conflicts = data || result.conflicts;
var conflicts = Array.isArray(data) ? data : (data.conflicts || []);
self.log('CONFLICTS', 'Complete', { count: conflicts.length });
return data;
}).catch(function(err) {
self.log('ERROR', 'Conflicts check failed: ' + err.message);
return result.conflicts;
});
// Wait for all with graceful degradation
return Promise.all([p1, p2, p3, p4, p5]).then(function() {
var totalTime = Date.now() - self.loadStartTime;
self.log('LOADED', 'All data fetched in ' + totalTime + 'ms');
return [result.status, result.scan, result.conflicts, result.tor, result.ssl];
});
},
render: function(data) {
var self = this;
var renderStart = Date.now();
this.log('RENDER', 'Starting DOM construction');
var status = data[0] || {};
var scanResult = data[1] || {};
var conflictsResult = data[2] || {};
var torResult = data[3] || {};
var sslResult = data[4] || {};
// Normalize data
this.log('RENDER', 'Normalizing data structures');
var services = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
var conflicts = Array.isArray(conflictsResult) ? conflictsResult : (conflictsResult.conflicts || []);
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
this.log('DATA', 'Final counts', {
services: services.length,
conflicts: conflicts.length,
tor: torServices.length,
ssl: sslBackends.length
});
var exposableServices = services.filter(function(svc) { return svc.external; });
// Build exposure lookup maps
var exposedTor = {};
var exposedSsl = {};
torServices.forEach(function(t) { exposedTor[t.service] = t; });
sslBackends.forEach(function(s) { exposedSsl[s.service] = s; });
// Find unexposed services (suggestions)
var suggestions = exposableServices.filter(function(svc) {
var name = self.getServiceName(svc);
return !exposedTor[name] && !exposedSsl[name];
});
this.log('SUGGESTIONS', 'Unexposed services found', {
count: suggestions.length,
names: suggestions.slice(0, 5).map(function(s) { return s.name || s.process; })
});
// Build view content progressively with timing
var content = [];
this.log('DOM', '1/6 Page header');
content.push(this.renderPageHeader(status));
if (conflicts.length > 0) {
this.log('DOM', '2/6 Conflicts banner (active)');
content.push(this.renderConflictsBanner(conflicts));
} else {
this.log('DOM', '2/6 Conflicts banner (skipped - none)');
}
this.log('DOM', '3/6 Stats grid');
content.push(this.renderStatsGrid(status, torServices, sslBackends, exposableServices));
if (suggestions.length > 0) {
this.log('DOM', '4/6 Suggestions card (active)');
content.push(this.renderSuggestionsCard(suggestions));
} else {
this.log('DOM', '4/6 Suggestions card (skipped - none)');
}
this.log('DOM', '5/6 Service cards row');
content.push(E('div', { 'class': 'exp-row' }, [
E('div', { 'style': 'flex: 1' }, [
this.renderTorServicesCard(torServices)
]),
E('div', { 'style': 'flex: 1' }, [
this.renderSslBackendsCard(sslBackends)
])
]));
this.log('DOM', '6/6 Quick actions');
content.push(this.renderQuickActions());
// Filter nulls
content = content.filter(Boolean);
// Main wrapper with animation
var view = E('div', { 'class': 'exposure-dashboard exp-fade-in' }, content);
// Setup polling
if (!this.pollRegistered) {
this.pollRegistered = true;
this.log('POLL', 'Auto-refresh registered (30s interval)');
poll.add(function() {
self.log('POLL', 'Refreshing dashboard...');
return self.refreshDashboard();
}, 30);
}
var renderTime = Date.now() - renderStart;
var totalTime = Date.now() - this.loadStartTime;
this.log('COMPLETE', 'Dashboard ready', {
renderTime: renderTime + 'ms',
totalTime: totalTime + 'ms'
});
return view;
},
getServiceName: function(svc) {
var name = svc.name ? svc.name.toLowerCase().replace(/\s+/g, '') : svc.process;
return name.replace(/[^a-z0-9]/g, '');
},
renderPageHeader: function(status) {
var servicesData = status.services || {};
var torData = status.tor || {};
var sslData = status.ssl || {};
return E('div', { 'class': 'exp-page-header' }, [
E('div', {}, [
E('h1', { 'class': 'exp-page-title' }, [
E('span', { 'class': 'exp-page-title-icon' }, '\uD83D\uDD0C'),
'Service Exposure Manager'
]),
E('p', { 'class': 'exp-page-subtitle' },
'Expose local services via Tor Hidden Services (.onion) or HAProxy SSL reverse proxy')
]),
E('div', { 'class': 'exp-header-badges' }, [
E('div', { 'class': 'exp-header-badge' }, [
E('span', { 'style': 'color: #64ffda;' }, String(servicesData.external || 0)),
' Exposable'
]),
E('div', { 'class': 'exp-header-badge' }, [
E('span', { 'style': 'color: #9b59b6;' }, String(torData.count || 0)),
' Tor'
]),
E('div', { 'class': 'exp-header-badge' }, [
E('span', { 'style': 'color: #27ae60;' }, String(sslData.count || 0)),
' SSL'
])
])
]);
},
renderConflictsBanner: function(conflicts) {
return E('div', { 'class': 'exp-card exp-warning-card' }, [
E('div', { 'class': 'exp-card-body', 'style': 'display: flex; align-items: center; gap: 16px;' }, [
E('span', { 'style': 'font-size: 32px;' }, '\u26A0\uFE0F'),
E('div', { 'style': 'flex: 1;' }, [
E('div', { 'style': 'font-weight: 600; font-size: 16px; margin-bottom: 4px; color: #f39c12;' },
conflicts.length + ' Port Conflict(s) Detected'),
E('div', { 'style': 'color: #8892b0;' },
conflicts.map(function(c) {
return 'Port ' + c.port + ': ' + (c.services || []).join(', ');
}).join(' | '))
])
])
]);
},
renderStatsGrid: function(status, torServices, sslBackends, exposableServices) {
var servicesData = status.services || {};
return E('div', { 'class': 'exp-stats-grid' }, [
E('div', { 'class': 'exp-stat-card' }, [
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD0C'),
E('div', { 'class': 'exp-stat-value' }, String(servicesData.total || 0)),
E('div', { 'class': 'exp-stat-label' }, 'Total Services'),
E('div', { 'class': 'exp-stat-trend' }, (servicesData.external || 0) + ' external')
]),
E('div', { 'class': 'exp-stat-card exp-stat-tor' }, [
E('div', { 'class': 'exp-stat-icon' }, '\uD83E\uDDC5'),
E('div', { 'class': 'exp-stat-value' }, String(torServices.length)),
E('div', { 'class': 'exp-stat-label' }, 'Tor Hidden Services'),
E('div', { 'class': 'exp-stat-trend' }, torServices.length > 0 ? 'Active' : 'None configured')
]),
E('div', { 'class': 'exp-stat-card exp-stat-ssl' }, [
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD12'),
E('div', { 'class': 'exp-stat-value' }, String(sslBackends.length)),
E('div', { 'class': 'exp-stat-label' }, 'SSL Backends'),
E('div', { 'class': 'exp-stat-trend' }, sslBackends.length > 0 ? 'HAProxy' : 'None configured')
]),
E('div', { 'class': 'exp-stat-card' }, [
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDCA1'),
E('div', { 'class': 'exp-stat-value' }, String(exposableServices.length)),
E('div', { 'class': 'exp-stat-label' }, 'Exposable'),
E('div', { 'class': 'exp-stat-trend' }, 'Ready for exposure')
])
]);
},
renderSuggestionsCard: function(suggestions) {
var self = this;
// Prioritize interesting services
var prioritized = suggestions.sort(function(a, b) {
var priority = {
'streamlit': 1, 'Streamlit': 1,
'gitea': 2, 'Gitea': 2,
'hexo': 3, 'HexoJS': 3,
'nextcloud': 4,
'jupyter': 5,
'flask': 6,
'django': 7
};
var pA = priority[a.name] || priority[a.process] || 100;
var pB = priority[b.name] || priority[b.process] || 100;
return pA - pB;
}).slice(0, 6);
return E('div', { 'class': 'exp-card exp-suggestions-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDCA1'),
'Suggested Services to Expose'
]),
E('a', { 'href': L.url('admin/secubox/network/exposure/services'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
'View All')
]),
E('div', { 'class': 'exp-card-body' }, [
E('div', { 'class': 'exp-suggestions-grid' }, prioritized.map(function(svc) {
var serviceName = self.getServiceName(svc);
var icon = self.getServiceIcon(svc);
return E('div', { 'class': 'exp-suggestion-item' }, [
E('div', { 'class': 'exp-suggestion-icon' }, icon),
E('div', { 'class': 'exp-suggestion-info' }, [
E('div', { 'class': 'exp-suggestion-name' }, svc.name || svc.process),
E('div', { 'class': 'exp-suggestion-port' }, 'Port ' + svc.port)
]),
E('div', { 'class': 'exp-suggestion-actions' }, [
E('button', {
'class': 'exp-btn exp-btn-tor exp-btn-xs',
'title': 'Add Tor Hidden Service',
'click': function() { self.handleAddTor(svc, serviceName); }
}, '\uD83E\uDDC5'),
E('button', {
'class': 'exp-btn exp-btn-ssl exp-btn-xs',
'title': 'Add SSL Backend',
'click': function() { self.handleAddSsl(svc, serviceName); }
}, '\uD83D\uDD12')
])
]);
}))
])
]);
},
getServiceIcon: function(svc) {
var iconMap = {
'streamlit': '\uD83D\uDCCA',
'Streamlit': '\uD83D\uDCCA',
'gitea': '\uD83D\uDC19',
'Gitea': '\uD83D\uDC19',
'hexo': '\uD83D\uDCDD',
'HexoJS': '\uD83D\uDCDD',
'jupyter': '\uD83D\uDCD3',
'flask': '\uD83C\uDF76',
'django': '\uD83E\uDD8E',
'nextcloud': '\u2601\uFE0F',
'SSH': '\uD83D\uDD11',
'HAProxy': '\u2696\uFE0F',
'DNS': '\uD83C\uDF10',
'LuCI': '\uD83D\uDDA5\uFE0F',
'python': '\uD83D\uDC0D'
};
return iconMap[svc.name] || iconMap[svc.process] || '\uD83D\uDD0C';
},
renderTorServicesCard: function(torServices) {
var self = this;
if (torServices.length === 0) {
return E('div', { 'class': 'exp-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'),
'Tor Hidden Services'
]),
E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-tor exp-btn-sm' },
'+ Add')
]),
E('div', { 'class': 'exp-card-body' }, [
E('div', { 'class': 'exp-empty' }, [
E('div', { 'class': 'exp-empty-icon' }, '\uD83E\uDDC5'),
E('div', { 'class': 'exp-empty-text' }, 'No Tor hidden services'),
E('div', { 'class': 'exp-empty-hint' }, 'Services are accessible via .onion addresses')
])
])
]);
}
var items = torServices.slice(0, 5).map(function(svc) {
var onion = svc.onion || '';
var shortOnion = onion.length > 30 ? onion.substring(0, 28) + '...' : onion;
return E('div', { 'class': 'exp-service-item' }, [
E('div', { 'class': 'exp-service-icon' }, '\uD83E\uDDC5'),
E('div', { 'class': 'exp-service-info' }, [
E('div', { 'class': 'exp-service-name' }, svc.service),
E('div', { 'class': 'exp-service-detail exp-onion' }, shortOnion)
]),
E('button', {
'class': 'exp-btn exp-btn-danger exp-btn-xs',
'title': 'Remove',
'click': function() { self.handleRemoveTor(svc.service); }
}, '\u2715')
]);
});
var cardBody = [E('div', { 'class': 'exp-services-list' }, items)];
if (torServices.length > 5) {
cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' },
E('a', { 'href': L.url('admin/secubox/network/exposure/tor') },
'+' + (torServices.length - 5) + ' more')));
}
return E('div', { 'class': 'exp-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'),
'Tor Hidden Services (' + torServices.length + ')'
]),
E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
'Manage')
]),
E('div', { 'class': 'exp-card-body' }, cardBody)
]);
},
renderSslBackendsCard: function(sslBackends) {
var self = this;
if (sslBackends.length === 0) {
return E('div', { 'class': 'exp-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'),
'SSL Backends'
]),
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-ssl exp-btn-sm' },
'+ Add')
]),
E('div', { 'class': 'exp-card-body' }, [
E('div', { 'class': 'exp-empty' }, [
E('div', { 'class': 'exp-empty-icon' }, '\uD83D\uDD12'),
E('div', { 'class': 'exp-empty-text' }, 'No SSL backends'),
E('div', { 'class': 'exp-empty-hint' }, 'Add HTTPS reverse proxy via HAProxy')
])
])
]);
}
var items = sslBackends.slice(0, 5).map(function(backend) {
return E('div', { 'class': 'exp-service-item' }, [
E('div', { 'class': 'exp-service-icon' }, '\uD83D\uDD12'),
E('div', { 'class': 'exp-service-info' }, [
E('div', { 'class': 'exp-service-name' }, backend.service),
E('div', { 'class': 'exp-service-detail exp-domain' }, backend.domain)
]),
E('button', {
'class': 'exp-btn exp-btn-danger exp-btn-xs',
'title': 'Remove',
'click': function() { self.handleRemoveSsl(backend.service); }
}, '\u2715')
]);
});
var cardBody = [E('div', { 'class': 'exp-services-list' }, items)];
if (sslBackends.length > 5) {
cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' },
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl') },
'+' + (sslBackends.length - 5) + ' more')));
}
return E('div', { 'class': 'exp-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'),
'SSL Backends (' + sslBackends.length + ')'
]),
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
'Manage')
]),
E('div', { 'class': 'exp-card-body' }, cardBody)
]);
},
renderQuickActions: function() {
var self = this;
return E('div', { 'class': 'exp-card' }, [
E('div', { 'class': 'exp-card-header' }, [
E('div', { 'class': 'exp-card-title' }, [
E('span', { 'class': 'exp-card-title-icon' }, '\u26A1'),
'Quick Actions'
])
]),
E('div', { 'class': 'exp-card-body' }, [
E('div', { 'class': 'exp-quick-actions' }, [
E('a', {
'href': L.url('admin/secubox/network/exposure/services'),
'class': 'exp-action-btn'
}, [
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD0C'),
E('span', { 'class': 'exp-action-label' }, 'All Services')
]),
E('a', {
'href': L.url('admin/secubox/network/exposure/tor'),
'class': 'exp-action-btn'
}, [
E('span', { 'class': 'exp-action-icon' }, '\uD83E\uDDC5'),
E('span', { 'class': 'exp-action-label' }, 'Tor Services')
]),
E('a', {
'href': L.url('admin/secubox/network/exposure/ssl'),
'class': 'exp-action-btn'
}, [
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD12'),
E('span', { 'class': 'exp-action-label' }, 'SSL Backends')
]),
E('button', {
'class': 'exp-action-btn',
'click': function() { self.refreshDashboard(); }
}, [
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD04'),
E('span', { 'class': 'exp-action-label' }, 'Refresh')
])
])
])
]);
},
// === Progress Modal Helpers ===
createProgressModal: function(title, steps) {
var stepsContainer = E('div', { 'class': 'exp-progress-steps', 'id': 'progress-steps' });
steps.forEach(function(step, index) {
stepsContainer.appendChild(E('div', {
'class': 'exp-progress-step',
'id': 'step-' + index,
'data-status': 'pending'
}, [
E('div', { 'class': 'exp-step-indicator' }, [
E('span', { 'class': 'exp-step-number' }, String(index + 1)),
E('span', { 'class': 'exp-step-icon' })
]),
E('div', { 'class': 'exp-step-content' }, [
E('div', { 'class': 'exp-step-label' }, step.label),
E('div', { 'class': 'exp-step-detail', 'id': 'step-detail-' + index }, step.detail || '')
])
]));
});
return E('div', { 'class': 'exp-progress-modal' }, [
E('div', { 'class': 'exp-progress-header' }, [
E('div', { 'class': 'exp-progress-title' }, title),
E('div', { 'class': 'exp-progress-subtitle', 'id': 'progress-subtitle' }, 'Initializing...')
]),
stepsContainer,
E('div', { 'class': 'exp-progress-result', 'id': 'progress-result', 'style': 'display: none;' })
]);
},
updateStep: function(index, status, detail) {
var stepEl = document.getElementById('step-' + index);
var detailEl = document.getElementById('step-detail-' + index);
var subtitleEl = document.getElementById('progress-subtitle');
if (stepEl) {
stepEl.setAttribute('data-status', status);
if (detail && detailEl) {
detailEl.textContent = detail;
}
}
if (subtitleEl && status === 'active') {
var labelEl = stepEl ? stepEl.querySelector('.exp-step-label') : null;
if (labelEl) {
subtitleEl.textContent = labelEl.textContent + '...';
}
}
},
showProgressResult: function(success, message, details) {
var resultEl = document.getElementById('progress-result');
var subtitleEl = document.getElementById('progress-subtitle');
if (resultEl) {
resultEl.style.display = 'block';
resultEl.className = 'exp-progress-result ' + (success ? 'success' : 'error');
resultEl.innerHTML = '';
resultEl.appendChild(E('div', { 'class': 'exp-result-icon' }, success ? '\u2705' : '\u274C'));
resultEl.appendChild(E('div', { 'class': 'exp-result-message' }, message));
if (details) {
resultEl.appendChild(E('div', { 'class': 'exp-result-details' }, details));
}
}
if (subtitleEl) {
subtitleEl.textContent = success ? 'Completed successfully' : 'Operation failed';
}
},
// === Action Handlers ===
handleAddTor: function(svc, serviceName) {
var self = this;
ui.showModal('Add Tor Hidden Service', [
E('p', {}, 'Create a .onion address for ' + (svc.name || svc.process)),
E('div', { 'style': 'margin: 1rem 0;' }, [
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'),
E('input', {
'type': 'text',
'id': 'tor-svc-name',
'value': serviceName,
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
]),
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Local Port'),
E('input', {
'type': 'number',
'id': 'tor-local-port',
'value': svc.port,
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
]),
E('div', {}, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Onion Port (public)'),
E('input', {
'type': 'number',
'id': 'tor-onion-port',
'value': '80',
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
var name = document.getElementById('tor-svc-name').value;
var localPort = parseInt(document.getElementById('tor-local-port').value);
var onionPort = parseInt(document.getElementById('tor-onion-port').value);
if (!name) {
ui.addNotification(null, E('p', {}, 'Service name is required'), 'warning');
return;
}
ui.hideModal();
// Show progress modal
var progressContent = self.createProgressModal('Creating Tor Hidden Service', [
{ label: 'Validating configuration', detail: 'Checking service name and ports' },
{ label: 'Creating hidden service directory', detail: '/var/lib/tor/hidden_services/' + name },
{ label: 'Updating Tor configuration', detail: 'Adding HiddenServiceDir and HiddenServicePort' },
{ label: 'Restarting Tor daemon', detail: 'Applying configuration changes' },
{ label: 'Generating .onion address', detail: 'This may take up to 30 seconds' },
{ label: 'Finalizing', detail: 'Saving to UCI and syncing with Tor Shield' }
]);
ui.showModal('Creating Tor Hidden Service', [
progressContent,
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
ui.hideModal();
self.refreshDashboard();
}
}, 'Close')
])
]);
// Simulate step progression (actual API call happens in background)
self.updateStep(0, 'active', 'Validating ' + name + ':' + localPort + ' -> :' + onionPort);
setTimeout(function() {
self.updateStep(0, 'complete', 'Configuration valid');
self.updateStep(1, 'active');
}, 500);
setTimeout(function() {
self.updateStep(1, 'complete', 'Directory created');
self.updateStep(2, 'active');
}, 1000);
setTimeout(function() {
self.updateStep(2, 'complete', 'torrc updated');
self.updateStep(3, 'active');
}, 1500);
// Make actual API call
api.torAdd(name, localPort, onionPort).then(function(res) {
if (res.success) {
self.updateStep(3, 'complete', 'Tor restarted');
self.updateStep(4, 'complete', 'Address generated');
self.updateStep(5, 'complete', 'UCI and Tor Shield synced');
self.showProgressResult(true,
'Hidden service created successfully!',
res.onion ? E('code', { 'style': 'color: #9b59b6; font-size: 12px; word-break: break-all;' }, res.onion) : null
);
} else {
// Mark current step as error
for (var i = 3; i <= 5; i++) {
var stepEl = document.getElementById('step-' + i);
if (stepEl && stepEl.getAttribute('data-status') === 'active') {
self.updateStep(i, 'error', res.error || 'Failed');
break;
}
}
self.showProgressResult(false, 'Failed to create hidden service', res.error || 'Unknown error');
}
document.getElementById('progress-actions').style.display = 'block';
}).catch(function(err) {
self.updateStep(3, 'error', 'Connection failed');
self.showProgressResult(false, 'API Error', err.message || 'Unknown error');
document.getElementById('progress-actions').style.display = 'block';
});
}
}, 'Create Hidden Service')
])
]);
},
handleAddSsl: function(svc, serviceName) {
var self = this;
ui.showModal('Add SSL Backend', [
E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process)),
E('div', { 'style': 'margin: 1rem 0;' }, [
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'),
E('input', {
'type': 'text',
'id': 'ssl-svc-name',
'value': serviceName,
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
]),
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Domain (FQDN)'),
E('input', {
'type': 'text',
'id': 'ssl-domain',
'placeholder': serviceName + '.example.com',
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
]),
E('div', {}, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Backend Port'),
E('input', {
'type': 'number',
'id': 'ssl-port',
'value': svc.port,
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
})
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
var name = document.getElementById('ssl-svc-name').value;
var domain = document.getElementById('ssl-domain').value;
var port = parseInt(document.getElementById('ssl-port').value);
if (!domain) {
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
return;
}
ui.hideModal();
// Show progress modal
var progressContent = self.createProgressModal('Configuring SSL Backend', [
{ label: 'Validating configuration', detail: 'Checking domain and backend port' },
{ label: 'Creating HAProxy backend', detail: 'Adding server 127.0.0.1:' + port },
{ label: 'Creating virtual host', detail: 'Domain: ' + domain },
{ label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' },
{ label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' },
{ label: 'Reloading HAProxy', detail: 'Applying changes without downtime' }
]);
ui.showModal('Configuring SSL Backend', [
progressContent,
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
ui.hideModal();
self.refreshDashboard();
}
}, 'Close')
])
]);
// Simulate step progression
self.updateStep(0, 'active', 'Validating ' + name + ' -> ' + domain);
setTimeout(function() {
self.updateStep(0, 'complete', 'Configuration valid');
self.updateStep(1, 'active');
}, 400);
setTimeout(function() {
self.updateStep(1, 'complete', 'Backend ' + name + ' created');
self.updateStep(2, 'active');
}, 800);
setTimeout(function() {
self.updateStep(2, 'complete', 'VHost for ' + domain + ' created');
self.updateStep(3, 'active');
}, 1200);
// Make actual API call
api.sslAdd(name, domain, port).then(function(res) {
if (res.success) {
self.updateStep(3, 'complete', 'UCI committed');
self.updateStep(4, 'complete', 'Config regenerated');
self.updateStep(5, 'complete', 'HAProxy reloaded');
self.showProgressResult(true,
'SSL backend configured successfully!',
E('div', {}, [
E('div', { 'style': 'margin-bottom: 8px;' }, [
'Access via: ',
E('a', {
'href': 'https://' + domain,
'target': '_blank',
'style': 'color: #27ae60;'
}, 'https://' + domain)
]),
E('div', { 'style': 'font-size: 12px; color: #888;' },
'Note: Ensure SSL certificate is configured for ' + domain)
])
);
} else {
for (var i = 3; i <= 5; i++) {
var stepEl = document.getElementById('step-' + i);
if (stepEl && stepEl.getAttribute('data-status') !== 'complete') {
self.updateStep(i, 'error', res.error || 'Failed');
break;
}
}
self.showProgressResult(false, 'Failed to configure SSL backend', res.error || 'Unknown error');
}
document.getElementById('progress-actions').style.display = 'block';
}).catch(function(err) {
self.updateStep(3, 'error', 'Connection failed');
self.showProgressResult(false, 'API Error', err.message || 'Unknown error');
document.getElementById('progress-actions').style.display = 'block';
});
}
}, 'Create SSL Backend')
])
]);
},
handleRemoveTor: function(serviceName) {
var self = this;
ui.showModal('Remove Tor Hidden Service', [
E('p', {}, 'Remove the .onion address for ' + serviceName + '?'),
E('p', { 'style': 'color: #e74c3c;' }, 'Warning: The onion address will be permanently deleted.'),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'btn cbi-button-negative',
'click': function() {
ui.hideModal();
// Show progress modal
var progressContent = self.createProgressModal('Removing Tor Hidden Service', [
{ label: 'Removing from torrc', detail: 'Deleting HiddenServiceDir entry' },
{ label: 'Deleting hidden service directory', detail: '/var/lib/tor/hidden_services/' + serviceName },
{ label: 'Restarting Tor daemon', detail: 'Applying configuration changes' },
{ label: 'Updating UCI', detail: 'Removing from secubox-exposure and tor-shield' }
]);
ui.showModal('Removing Tor Hidden Service', [
progressContent,
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
ui.hideModal();
self.refreshDashboard();
}
}, 'Close')
])
]);
self.updateStep(0, 'active');
api.torRemove(serviceName).then(function(res) {
if (res.success) {
self.updateStep(0, 'complete');
self.updateStep(1, 'complete');
self.updateStep(2, 'complete');
self.updateStep(3, 'complete');
self.showProgressResult(true, 'Hidden service removed successfully');
} else {
self.updateStep(0, 'error', res.error);
self.showProgressResult(false, 'Failed to remove hidden service', res.error || 'Unknown error');
}
document.getElementById('progress-actions').style.display = 'block';
}).catch(function(err) {
self.updateStep(0, 'error', 'Connection failed');
self.showProgressResult(false, 'API Error', err.message);
document.getElementById('progress-actions').style.display = 'block';
});
}
}, 'Remove')
])
]);
},
handleRemoveSsl: function(serviceName) {
var self = this;
ui.showModal('Remove SSL Backend', [
E('p', {}, 'Remove HAProxy backend for ' + serviceName + '?'),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'btn cbi-button-negative',
'click': function() {
ui.hideModal();
// Show progress modal
var progressContent = self.createProgressModal('Removing SSL Backend', [
{ label: 'Removing virtual host', detail: 'Deleting vhost configuration' },
{ label: 'Removing backend', detail: 'Deleting backend ' + serviceName },
{ label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' },
{ label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' },
{ label: 'Reloading HAProxy', detail: 'Applying changes' }
]);
ui.showModal('Removing SSL Backend', [
progressContent,
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
ui.hideModal();
self.refreshDashboard();
}
}, 'Close')
])
]);
self.updateStep(0, 'active');
api.sslRemove(serviceName).then(function(res) {
if (res.success) {
self.updateStep(0, 'complete');
self.updateStep(1, 'complete');
self.updateStep(2, 'complete');
self.updateStep(3, 'complete');
self.updateStep(4, 'complete');
self.showProgressResult(true, 'SSL backend removed successfully');
} else {
self.updateStep(0, 'error', res.error);
self.showProgressResult(false, 'Failed to remove SSL backend', res.error || 'Unknown error');
}
document.getElementById('progress-actions').style.display = 'block';
}).catch(function(err) {
self.updateStep(0, 'error', 'Connection failed');
self.showProgressResult(false, 'API Error', err.message);
document.getElementById('progress-actions').style.display = 'block';
});
}
}, 'Remove')
])
]);
},
refreshDashboard: function() {
var self = this;
return Promise.all([
api.status(),
api.scan(),
api.conflicts(),
api.torList(),
api.sslList()
]).then(function(data) {
var container = document.querySelector('.exposure-dashboard');
if (container) {
var newView = self.render(data);
container.parentNode.replaceChild(newView, container);
}
});
},
showToast: function(message, type) {
var existing = document.querySelector('.exp-toast');
if (existing) existing.remove();
var iconMap = {
'success': '\u2705',
'error': '\u274C',
'warning': '\u26A0\uFE0F'
};
var colorMap = {
'success': '#22c55e',
'error': '#ef4444',
'warning': '#f97316'
};
var toast = E('div', {
'class': 'exp-toast',
'style': 'position: fixed; bottom: 24px; right: 24px; background: #1a1a2e; border: 1px solid ' + (colorMap[type] || '#333') + '; padding: 12px 20px; border-radius: 8px; color: #fff; z-index: 10000; display: flex; align-items: center; gap: 8px;'
}, [
E('span', {}, iconMap[type] || '\u2139\uFE0F'),
message
]);
document.body.appendChild(toast);
setTimeout(function() {
toast.remove();
}, 4000);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});