feat(exposure): KISS redesign with enriched service names and vhost integration
Collapse 4-tab UI into single-table view. Enrich scan with real names from uhttpd UCI, streamlit UCI, docker containers, glances and Lyrion. Add vhost_list RPCD method to show HAProxy domains and uhttpd instances. Fix RPC expect unwrapping, trim CSS from 870 to 178 lines. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc6a8f9c62
commit
4c8799d520
@ -2,81 +2,65 @@
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
|
||||
return baseclass.extend({
|
||||
callScan: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'scan',
|
||||
expect: { services: [] }
|
||||
}),
|
||||
|
||||
callConflicts: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'conflicts',
|
||||
expect: { conflicts: [] }
|
||||
}),
|
||||
|
||||
callStatus: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'status'
|
||||
}),
|
||||
|
||||
callTorList: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_list',
|
||||
expect: { services: [] }
|
||||
}),
|
||||
|
||||
callSslList: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_list',
|
||||
expect: { backends: [] }
|
||||
}),
|
||||
|
||||
callGetConfig: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'get_config',
|
||||
expect: { known_services: [] }
|
||||
}),
|
||||
|
||||
callFixPort: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'fix_port',
|
||||
params: ['service', 'port']
|
||||
}),
|
||||
|
||||
callTorAdd: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_add',
|
||||
params: ['service', 'local_port', 'onion_port']
|
||||
}),
|
||||
|
||||
callTorRemove: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_remove',
|
||||
params: ['service']
|
||||
}),
|
||||
|
||||
callSslAdd: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_add',
|
||||
params: ['service', 'domain', 'local_port']
|
||||
}),
|
||||
|
||||
callSslRemove: rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_remove',
|
||||
params: ['service']
|
||||
}),
|
||||
|
||||
scan: function() { return this.callScan(); },
|
||||
conflicts: function() { return this.callConflicts(); },
|
||||
status: function() { return this.callStatus(); },
|
||||
torList: function() { return this.callTorList(); },
|
||||
sslList: function() { return this.callSslList(); },
|
||||
getConfig: function() { return this.callGetConfig(); },
|
||||
fixPort: function(s, p) { return this.callFixPort(s, p); },
|
||||
torAdd: function(s, l, o) { return this.callTorAdd(s, l, o); },
|
||||
torRemove: function(s) { return this.callTorRemove(s); },
|
||||
sslAdd: function(s, d, p) { return this.callSslAdd(s, d, p); },
|
||||
sslRemove: function(s) { return this.callSslRemove(s); }
|
||||
var callScan = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'scan',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTorList = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callSslList = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTorAdd = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_add',
|
||||
params: ['service', 'local_port', 'onion_port'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTorRemove = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'tor_remove',
|
||||
params: ['service'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callSslAdd = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_add',
|
||||
params: ['service', 'domain', 'local_port'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callSslRemove = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'ssl_remove',
|
||||
params: ['service'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callVhostList = rpc.declare({
|
||||
object: 'luci.exposure',
|
||||
method: 'vhost_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
scan: function() { return callScan(); },
|
||||
torList: function() { return callTorList(); },
|
||||
sslList: function() { return callSslList(); },
|
||||
vhostList: function() { return callVhostList(); },
|
||||
torAdd: function(s, l, o) { return callTorAdd(s, l, o); },
|
||||
torRemove: function(s) { return callTorRemove(s); },
|
||||
sslAdd: function(s, d, p) { return callSslAdd(s, d, p); },
|
||||
sslRemove: function(s) { return callSslRemove(s); }
|
||||
});
|
||||
|
||||
@ -1,869 +1,177 @@
|
||||
/* SecuBox Service Exposure Manager - Dashboard Styles */
|
||||
/* Unified theme matching SecuBox HAProxy dashboard */
|
||||
/* SecuBox Service Exposure - KISS theme */
|
||||
|
||||
:root {
|
||||
--exp-bg-primary: #0d1117;
|
||||
--exp-bg-secondary: #161b22;
|
||||
--exp-bg-tertiary: #1a1a2e;
|
||||
--exp-border: #30363d;
|
||||
--exp-text-primary: #e6edf3;
|
||||
--exp-text-secondary: #8892b0;
|
||||
--exp-text-muted: #6e7681;
|
||||
--exp-accent: #64ffda;
|
||||
--exp-tor: #9b59b6;
|
||||
--exp-ssl: #27ae60;
|
||||
--exp-success: #22c55e;
|
||||
--exp-warning: #f97316;
|
||||
--exp-danger: #ef4444;
|
||||
--exp-bg-primary: #0d1117;
|
||||
--exp-bg-secondary: #161b22;
|
||||
--exp-bg-tertiary: #1a1a2e;
|
||||
--exp-border: #30363d;
|
||||
--exp-text-primary: #e6edf3;
|
||||
--exp-text-secondary: #8892b0;
|
||||
--exp-text-muted: #6e7681;
|
||||
--exp-accent: #64ffda;
|
||||
--exp-tor: #9b59b6;
|
||||
--exp-ssl: #27ae60;
|
||||
--exp-danger: #ef4444;
|
||||
}
|
||||
|
||||
.exposure-dashboard {
|
||||
padding: 0;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
/* Header */
|
||||
.exp-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
}
|
||||
|
||||
.exp-page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--exp-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exp-page-title-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.exp-page-subtitle {
|
||||
color: var(--exp-text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.exp-header-badges {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exp-header-badge {
|
||||
background: var(--exp-bg-tertiary);
|
||||
border: 1px solid var(--exp-border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--exp-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.exp-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.exp-stat-card {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border: 1px solid var(--exp-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.exp-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.exp-stat-card.exp-stat-tor {
|
||||
border-color: rgba(155, 89, 182, 0.4);
|
||||
}
|
||||
|
||||
.exp-stat-card.exp-stat-ssl {
|
||||
border-color: rgba(39, 174, 96, 0.4);
|
||||
}
|
||||
|
||||
.exp-stat-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exp-stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--exp-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.exp-stat-card.exp-stat-tor .exp-stat-value {
|
||||
color: var(--exp-tor);
|
||||
}
|
||||
|
||||
.exp-stat-card.exp-stat-ssl .exp-stat-value {
|
||||
color: var(--exp-ssl);
|
||||
}
|
||||
|
||||
.exp-stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--exp-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.exp-stat-trend {
|
||||
font-size: 12px;
|
||||
color: var(--exp-text-muted);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.exp-card {
|
||||
background: var(--exp-bg-secondary);
|
||||
border: 1px solid var(--exp-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exp-card.exp-warning-card {
|
||||
border-left: 4px solid var(--exp-warning);
|
||||
}
|
||||
|
||||
.exp-card.exp-suggestions-card {
|
||||
border-left: 4px solid var(--exp-accent);
|
||||
}
|
||||
|
||||
.exp-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.exp-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.exp-card-title-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.exp-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.exp-card-body.no-padding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Row layout */
|
||||
.exp-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exp-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.exp-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--exp-text-muted);
|
||||
}
|
||||
|
||||
.exp-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.exp-empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--exp-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exp-empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--exp-text-muted);
|
||||
}
|
||||
|
||||
/* Suggestions Grid */
|
||||
.exp-suggestions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exp-suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--exp-bg-tertiary);
|
||||
border: 1px solid var(--exp-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.exp-suggestion-item:hover {
|
||||
border-color: var(--exp-accent);
|
||||
background: rgba(100, 255, 218, 0.05);
|
||||
}
|
||||
|
||||
.exp-suggestion-icon {
|
||||
font-size: 28px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exp-suggestion-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exp-suggestion-name {
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exp-suggestion-port {
|
||||
font-size: 12px;
|
||||
color: var(--exp-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.exp-suggestion-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Services list */
|
||||
.exp-services-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exp-service-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--exp-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.exp-service-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.exp-service-icon {
|
||||
font-size: 24px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exp-service-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exp-service-name {
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exp-service-detail {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.exp-service-detail.exp-onion {
|
||||
color: var(--exp-tor);
|
||||
}
|
||||
|
||||
.exp-service-detail.exp-domain {
|
||||
color: var(--exp-ssl);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.exp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.exp-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.exp-btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exp-btn-xs {
|
||||
padding: 4px 8px;
|
||||
font-size: 16px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.exp-btn-primary {
|
||||
background: linear-gradient(135deg, #64ffda, #4fc3f7);
|
||||
color: #0d1117;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.exp-btn-primary:hover {
|
||||
box-shadow: 0 4px 15px rgba(100, 255, 218, 0.4);
|
||||
}
|
||||
|
||||
.exp-btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--exp-text-secondary);
|
||||
border-color: var(--exp-border);
|
||||
}
|
||||
|
||||
.exp-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--exp-text-secondary);
|
||||
}
|
||||
|
||||
.exp-btn-tor {
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: var(--exp-tor);
|
||||
border-color: var(--exp-tor);
|
||||
}
|
||||
|
||||
.exp-btn-tor:hover {
|
||||
background: var(--exp-tor);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.exp-btn-ssl {
|
||||
background: rgba(39, 174, 96, 0.2);
|
||||
color: var(--exp-ssl);
|
||||
border-color: var(--exp-ssl);
|
||||
}
|
||||
|
||||
.exp-btn-ssl:hover {
|
||||
background: var(--exp-ssl);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.exp-btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--exp-danger);
|
||||
border-color: var(--exp-danger);
|
||||
}
|
||||
|
||||
.exp-btn-danger:hover {
|
||||
background: var(--exp-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.exp-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exp-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
background: var(--exp-bg-tertiary);
|
||||
border: 1px solid var(--exp-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.exp-action-btn:hover {
|
||||
background: rgba(100, 255, 218, 0.1);
|
||||
border-color: var(--exp-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.exp-action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.exp-action-label {
|
||||
font-size: 12px;
|
||||
color: var(--exp-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
/* Table */
|
||||
.exp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--exp-bg-secondary);
|
||||
border: 1px solid var(--exp-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exp-table th,
|
||||
.exp-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
}
|
||||
|
||||
.exp-table th {
|
||||
color: var(--exp-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--exp-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.exp-table td {
|
||||
color: var(--exp-text-primary);
|
||||
color: var(--exp-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exp-table tr:hover td {
|
||||
background: rgba(100, 255, 218, 0.03);
|
||||
background: rgba(100, 255, 218, 0.03);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.exp-row-internal td {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
.exp-mono { font-family: 'SF Mono', Monaco, monospace; }
|
||||
.exp-text-muted { color: var(--exp-text-muted); }
|
||||
.exp-small { font-size: 12px; }
|
||||
|
||||
/* Badges */
|
||||
.exp-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.exp-badge-success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--exp-success);
|
||||
}
|
||||
|
||||
.exp-badge-warning {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: var(--exp-warning);
|
||||
}
|
||||
|
||||
.exp-badge-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--exp-danger);
|
||||
}
|
||||
|
||||
.exp-badge-info {
|
||||
background: rgba(100, 255, 218, 0.2);
|
||||
color: var(--exp-accent);
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.exp-badge-tor {
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: var(--exp-tor);
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: var(--exp-tor);
|
||||
}
|
||||
|
||||
.exp-badge-ssl {
|
||||
background: rgba(39, 174, 96, 0.2);
|
||||
color: var(--exp-ssl);
|
||||
background: rgba(39, 174, 96, 0.2);
|
||||
color: var(--exp-ssl);
|
||||
}
|
||||
|
||||
/* Monospace text */
|
||||
.exp-mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||
/* Buttons */
|
||||
.exp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.exp-toast {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
.exp-btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--exp-text-secondary);
|
||||
border-color: var(--exp-border);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.exp-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--exp-text-secondary);
|
||||
}
|
||||
|
||||
/* Toggle switches (from services.js) */
|
||||
/* Toggle switches */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #333;
|
||||
transition: 0.3s;
|
||||
border-radius: 26px;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #333;
|
||||
transition: 0.2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #666;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: #1a1a2e;
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #666;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
input:checked + .tor-slider {
|
||||
background-color: rgba(155, 89, 182, 0.3);
|
||||
border: 1px solid #9b59b6;
|
||||
background-color: rgba(155, 89, 182, 0.3);
|
||||
border: 1px solid #9b59b6;
|
||||
}
|
||||
|
||||
input:checked + .tor-slider:before {
|
||||
background-color: #9b59b6;
|
||||
background-color: #9b59b6;
|
||||
}
|
||||
|
||||
input:checked + .ssl-slider {
|
||||
background-color: rgba(39, 174, 96, 0.3);
|
||||
border: 1px solid #27ae60;
|
||||
background-color: rgba(39, 174, 96, 0.3);
|
||||
border: 1px solid #27ae60;
|
||||
}
|
||||
|
||||
input:checked + .ssl-slider:before {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.toggle-slider:hover {
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
/* === Progress Modal Styles === */
|
||||
|
||||
.exp-progress-modal {
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.exp-progress-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--exp-border);
|
||||
}
|
||||
|
||||
.exp-progress-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.exp-progress-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--exp-text-muted);
|
||||
}
|
||||
|
||||
.exp-progress-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exp-progress-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--exp-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--exp-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="pending"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="active"] {
|
||||
border-left-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="complete"] {
|
||||
border-left-color: var(--exp-success);
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] {
|
||||
border-left-color: var(--exp-danger);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.exp-step-indicator {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--exp-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="active"] .exp-step-indicator {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="complete"] .exp-step-indicator {
|
||||
background: var(--exp-success);
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] .exp-step-indicator {
|
||||
background: var(--exp-danger);
|
||||
}
|
||||
|
||||
.exp-step-number {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-secondary);
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="active"] .exp-step-number,
|
||||
.exp-progress-step[data-status="complete"] .exp-step-number,
|
||||
.exp-progress-step[data-status="error"] .exp-step-number {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="complete"] .exp-step-number::before {
|
||||
content: '\2713';
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="complete"] .exp-step-number {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="complete"] .exp-step-number::before {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] .exp-step-number::before {
|
||||
content: '\2717';
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] .exp-step-number {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] .exp-step-number::before {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="active"] .exp-step-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #3b82f6;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.3); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
.exp-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exp-step-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--exp-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.exp-step-detail {
|
||||
font-size: 12px;
|
||||
color: var(--exp-text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="active"] .exp-step-detail {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.exp-progress-step[data-status="error"] .exp-step-detail {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Progress Result */
|
||||
.exp-progress-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exp-progress-result.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid var(--exp-success);
|
||||
}
|
||||
|
||||
.exp-progress-result.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid var(--exp-danger);
|
||||
}
|
||||
|
||||
.exp-result-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exp-result-message {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--exp-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exp-result-details {
|
||||
font-size: 13px;
|
||||
color: var(--exp-text-secondary);
|
||||
}
|
||||
|
||||
/* === Loading Skeleton === */
|
||||
|
||||
.exp-skeleton {
|
||||
background: linear-gradient(90deg, var(--exp-bg-tertiary) 25%, var(--exp-bg-secondary) 50%, var(--exp-bg-tertiary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.exp-skeleton-stat {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.exp-skeleton-card {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.exp-skeleton-text {
|
||||
height: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exp-skeleton-text.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
/* === Fade-in Animation === */
|
||||
|
||||
.exp-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* === Loading State === */
|
||||
|
||||
.exp-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(13, 17, 23, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.exp-loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--exp-border);
|
||||
border-top-color: var(--exp-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
@ -4,457 +4,310 @@
|
||||
'require ui';
|
||||
'require exposure/api as api';
|
||||
|
||||
/**
|
||||
* Unified Service Exposure Manager
|
||||
* Toggle Tor Hidden Services and SSL/HAProxy exposure with checkboxes
|
||||
*/
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
api.scan(),
|
||||
api.getConfig(),
|
||||
api.torList(),
|
||||
api.sslList()
|
||||
]);
|
||||
},
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
api.scan(),
|
||||
api.torList(),
|
||||
api.sslList(),
|
||||
api.vhostList()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var scanResult = data[0] || {};
|
||||
var configResult = data[1] || {};
|
||||
var torResult = data[2] || {};
|
||||
var sslResult = data[3] || {};
|
||||
render: function(data) {
|
||||
var scanResult = data[0] || {};
|
||||
var torResult = data[1] || {};
|
||||
var sslResult = data[2] || {};
|
||||
var vhostResult = data[3] || {};
|
||||
|
||||
var services = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
|
||||
var knownServices = Array.isArray(configResult) ? configResult : (configResult.known_services || []);
|
||||
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
|
||||
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
|
||||
var self = this;
|
||||
var services = scanResult.services || [];
|
||||
var torServices = torResult.services || [];
|
||||
var sslBackends = sslResult.backends || [];
|
||||
var haproxyVhosts = vhostResult.haproxy || [];
|
||||
var uhttpdVhosts = vhostResult.uhttpd || [];
|
||||
var self = this;
|
||||
|
||||
// Build lookup maps for current exposure status
|
||||
var torByService = {};
|
||||
torServices.forEach(function(t) {
|
||||
torByService[t.service] = t;
|
||||
});
|
||||
// Build tor lookup by port (with name fallback)
|
||||
var torByPort = {};
|
||||
torServices.forEach(function(t) {
|
||||
var port = self.parseBackendPort(t.backend);
|
||||
if (port) torByPort[port] = t;
|
||||
});
|
||||
var torByName = {};
|
||||
torServices.forEach(function(t) { torByName[t.service] = t; });
|
||||
|
||||
var sslByService = {};
|
||||
sslBackends.forEach(function(s) {
|
||||
sslByService[s.service] = s;
|
||||
});
|
||||
// Build ssl lookup by port (with name fallback)
|
||||
var sslByPort = {};
|
||||
sslBackends.forEach(function(s) {
|
||||
var port = self.parseBackendPort(s.backend);
|
||||
if (port) sslByPort[port] = s;
|
||||
});
|
||||
var sslByName = {};
|
||||
sslBackends.forEach(function(s) { sslByName[s.service] = s; });
|
||||
|
||||
// Inject CSS
|
||||
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);
|
||||
}
|
||||
// Build uhttpd name lookup by port
|
||||
var uhttpdByPort = {};
|
||||
uhttpdVhosts.forEach(function(u) {
|
||||
if (u.port) uhttpdByPort[u.port] = u;
|
||||
});
|
||||
|
||||
// Filter to only external services (exposable)
|
||||
var exposableServices = services.filter(function(svc) {
|
||||
return svc.external;
|
||||
});
|
||||
// Build HAProxy domains lookup by backend_port (multiple domains per port)
|
||||
var domainsByPort = {};
|
||||
haproxyVhosts.forEach(function(v) {
|
||||
if (!v.enabled || !v.backend_port || !v.domain) return;
|
||||
if (!domainsByPort[v.backend_port]) domainsByPort[v.backend_port] = [];
|
||||
domainsByPort[v.backend_port].push(v);
|
||||
});
|
||||
|
||||
var view = E('div', { 'class': 'exposure-dashboard' }, [
|
||||
E('h2', {}, 'Service Exposure Manager'),
|
||||
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
|
||||
'Enable or disable exposure of local services via Tor Hidden Services (.onion) or SSL Web (HAProxy)'),
|
||||
// Inject CSS
|
||||
if (!document.querySelector('link[href*="exposure/dashboard.css"]')) {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = L.resource('exposure/dashboard.css');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
E('div', { 'class': 'exposure-stats', 'style': 'display: flex; gap: 1rem; margin-bottom: 1.5rem;' }, [
|
||||
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #333;' }, [
|
||||
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #64ffda;' }, String(exposableServices.length)),
|
||||
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'Exposable Services')
|
||||
]),
|
||||
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #9b59b6;' }, [
|
||||
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #9b59b6;' }, String(torServices.length)),
|
||||
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'Tor Hidden Services')
|
||||
]),
|
||||
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #27ae60;' }, [
|
||||
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #27ae60;' }, String(sslBackends.length)),
|
||||
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'SSL Backends')
|
||||
])
|
||||
]),
|
||||
var torCount = torServices.length;
|
||||
var sslCount = sslBackends.length;
|
||||
var domainCount = haproxyVhosts.filter(function(v) { return v.enabled; }).length;
|
||||
|
||||
// Main table
|
||||
E('div', { 'class': 'exposure-section' }, [
|
||||
E('div', { 'class': 'exposure-section-header' }, [
|
||||
E('div', { 'class': 'exposure-section-title' }, [
|
||||
E('span', { 'class': 'icon' }, '\ud83d\udd0c'),
|
||||
'Service Exposure Control'
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'btn-action btn-primary',
|
||||
'click': function() { location.reload(); }
|
||||
}, '\u21bb Refresh')
|
||||
]),
|
||||
var rows = services.map(function(svc) {
|
||||
var torInfo = torByPort[svc.port] || torByName[svc.name] || torByName[svc.process] || null;
|
||||
var sslInfo = sslByPort[svc.port] || sslByName[svc.name] || sslByName[svc.process] || null;
|
||||
var uhttpdInfo = uhttpdByPort[svc.port] || null;
|
||||
var domains = domainsByPort[svc.port] || [];
|
||||
var isExternal = svc.external;
|
||||
|
||||
exposableServices.length > 0 ?
|
||||
E('table', { 'class': 'exposure-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', { 'style': 'width: 60px;' }, 'Port'),
|
||||
E('th', {}, 'Service'),
|
||||
E('th', { 'style': 'width: 80px;' }, 'Process'),
|
||||
E('th', { 'style': 'width: 120px; text-align: center;' }, [
|
||||
E('span', { 'style': 'color: #9b59b6;' }, '\ud83e\uddc5 Tor')
|
||||
]),
|
||||
E('th', { 'style': 'width: 120px; text-align: center;' }, [
|
||||
E('span', { 'style': 'color: #27ae60;' }, '\ud83d\udd12 SSL')
|
||||
]),
|
||||
E('th', { 'style': 'width: 200px;' }, 'Details')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
exposableServices.map(function(svc) {
|
||||
var serviceName = self.getServiceName(svc);
|
||||
var torInfo = torByService[serviceName];
|
||||
var sslInfo = sslByService[serviceName];
|
||||
var isTorEnabled = !!torInfo;
|
||||
var isSslEnabled = !!sslInfo;
|
||||
// Display name comes from enriched scan; show process as subtitle
|
||||
var displayName = svc.name || svc.process;
|
||||
var subName = (svc.name && svc.name !== svc.process) ? svc.process : null;
|
||||
|
||||
return E('tr', { 'data-service': serviceName, 'data-port': svc.port }, [
|
||||
E('td', { 'style': 'font-weight: 600; font-family: monospace;' }, String(svc.port)),
|
||||
E('td', {}, [
|
||||
E('strong', {}, svc.name || svc.process),
|
||||
svc.name !== svc.process ? E('small', { 'style': 'color: #8892b0; display: block;' }, svc.process) : null
|
||||
]),
|
||||
E('td', { 'style': 'font-family: monospace; font-size: 0.8rem; color: #8892b0;' }, svc.process),
|
||||
// Tor checkbox
|
||||
E('td', { 'style': 'text-align: center;' }, [
|
||||
E('label', { 'class': 'toggle-switch' }, [
|
||||
E('input', {
|
||||
'type': 'checkbox',
|
||||
'checked': isTorEnabled,
|
||||
'data-service': serviceName,
|
||||
'data-port': svc.port,
|
||||
'data-type': 'tor',
|
||||
'change': ui.createHandlerFn(self, 'handleToggleTor', svc, serviceName, isTorEnabled)
|
||||
}),
|
||||
E('span', { 'class': 'toggle-slider tor-slider' })
|
||||
])
|
||||
]),
|
||||
// SSL checkbox
|
||||
E('td', { 'style': 'text-align: center;' }, [
|
||||
E('label', { 'class': 'toggle-switch' }, [
|
||||
E('input', {
|
||||
'type': 'checkbox',
|
||||
'checked': isSslEnabled,
|
||||
'data-service': serviceName,
|
||||
'data-port': svc.port,
|
||||
'data-type': 'ssl',
|
||||
'change': ui.createHandlerFn(self, 'handleToggleSsl', svc, serviceName, isSslEnabled, sslInfo)
|
||||
}),
|
||||
E('span', { 'class': 'toggle-slider ssl-slider' })
|
||||
])
|
||||
]),
|
||||
// Details column
|
||||
E('td', { 'style': 'font-size: 0.8rem;' }, [
|
||||
torInfo ? E('div', { 'style': 'color: #9b59b6; margin-bottom: 2px;' }, [
|
||||
E('code', { 'style': 'font-size: 0.7rem;' }, (torInfo.onion || '').substring(0, 20) + '...')
|
||||
]) : null,
|
||||
sslInfo ? E('div', { 'style': 'color: #27ae60;' }, [
|
||||
E('code', { 'style': 'font-size: 0.7rem;' }, sslInfo.domain || 'N/A')
|
||||
]) : null,
|
||||
!torInfo && !sslInfo ? E('span', { 'style': 'color: #666;' }, 'Not exposed') : null
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
]) :
|
||||
E('div', { 'class': 'exposure-empty' }, [
|
||||
E('div', { 'class': 'icon' }, '\ud83d\udd0c'),
|
||||
E('p', {}, 'No exposable services detected'),
|
||||
E('small', {}, 'Services bound to 0.0.0.0 or :: will appear here')
|
||||
]),
|
||||
// Exposure info fragments
|
||||
var infoItems = [];
|
||||
if (torInfo && torInfo.onion) {
|
||||
var onion = torInfo.onion;
|
||||
infoItems.push(E('span', { 'class': 'exp-badge exp-badge-tor', 'title': onion },
|
||||
onion.substring(0, 16) + '...'));
|
||||
}
|
||||
domains.forEach(function(v) {
|
||||
infoItems.push(E('span', {
|
||||
'class': 'exp-badge exp-badge-ssl',
|
||||
'title': v.domain + (v.acme ? ' (ACME)' : '')
|
||||
}, v.domain));
|
||||
});
|
||||
if (infoItems.length === 0 && sslInfo && sslInfo.domain) {
|
||||
infoItems.push(E('span', { 'class': 'exp-badge exp-badge-ssl' }, sslInfo.domain));
|
||||
}
|
||||
|
||||
// Toggle switch styles
|
||||
E('style', {}, `
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #333;
|
||||
transition: 0.3s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #666;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .toggle-slider {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
input:checked + .tor-slider {
|
||||
background-color: rgba(155, 89, 182, 0.3);
|
||||
border: 1px solid #9b59b6;
|
||||
}
|
||||
input:checked + .tor-slider:before {
|
||||
background-color: #9b59b6;
|
||||
}
|
||||
input:checked + .ssl-slider {
|
||||
background-color: rgba(39, 174, 96, 0.3);
|
||||
border: 1px solid #27ae60;
|
||||
}
|
||||
input:checked + .ssl-slider:before {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
.toggle-slider:hover {
|
||||
border: 1px solid #555;
|
||||
}
|
||||
`)
|
||||
])
|
||||
]);
|
||||
return E('tr', { 'class': isExternal ? '' : 'exp-row-internal' }, [
|
||||
E('td', { 'class': 'exp-mono' }, String(svc.port)),
|
||||
E('td', {}, [
|
||||
E('strong', {}, displayName),
|
||||
subName ? E('span', { 'class': 'exp-text-muted exp-small' }, ' (' + subName + ')') : null
|
||||
]),
|
||||
E('td', { 'class': 'exp-mono exp-text-muted' },
|
||||
svc.address.replace(/^.*:/, '').length < 4 ? svc.address : (isExternal ? '0.0.0.0' : '127.0.0.1')),
|
||||
// Tor toggle
|
||||
E('td', { 'style': 'text-align: center;' },
|
||||
isExternal ? E('label', { 'class': 'toggle-switch' }, [
|
||||
E('input', {
|
||||
'type': 'checkbox',
|
||||
'checked': !!torInfo,
|
||||
'change': ui.createHandlerFn(self, 'handleTorToggle', svc, torInfo)
|
||||
}),
|
||||
E('span', { 'class': 'toggle-slider tor-slider' })
|
||||
]) : E('span', { 'class': 'exp-text-muted' }, '-')
|
||||
),
|
||||
// SSL toggle
|
||||
E('td', { 'style': 'text-align: center;' },
|
||||
isExternal ? E('label', { 'class': 'toggle-switch' }, [
|
||||
E('input', {
|
||||
'type': 'checkbox',
|
||||
'checked': !!(sslInfo || domains.length > 0),
|
||||
'change': ui.createHandlerFn(self, 'handleSslToggle', svc, sslInfo, domains)
|
||||
}),
|
||||
E('span', { 'class': 'toggle-slider ssl-slider' })
|
||||
]) : E('span', { 'class': 'exp-text-muted' }, '-')
|
||||
),
|
||||
// Exposure info
|
||||
E('td', {}, infoItems.length > 0 ? infoItems :
|
||||
(isExternal ? E('span', { 'class': 'exp-text-muted' }, 'Not exposed') : E('span', { 'class': 'exp-text-muted' }, 'Local only')))
|
||||
]);
|
||||
});
|
||||
|
||||
return view;
|
||||
},
|
||||
return E('div', { 'class': 'exposure-dashboard' }, [
|
||||
E('div', { 'class': 'exp-page-header' }, [
|
||||
E('h2', { 'style': 'margin: 0; color: var(--exp-text-primary);' }, 'Service Exposure'),
|
||||
E('div', { 'style': 'display: flex; gap: 12px; align-items: center;' }, [
|
||||
E('span', { 'class': 'exp-badge exp-badge-tor' }, torCount + ' Tor'),
|
||||
E('span', { 'class': 'exp-badge exp-badge-ssl' }, domainCount + ' Domains'),
|
||||
E('button', {
|
||||
'class': 'exp-btn exp-btn-secondary',
|
||||
'click': function() { window.location.reload(); }
|
||||
}, 'Refresh')
|
||||
])
|
||||
]),
|
||||
|
||||
getServiceName: function(svc) {
|
||||
var name = svc.name ? svc.name.toLowerCase().replace(/\s+/g, '') : svc.process;
|
||||
// Clean up common variations
|
||||
return name.replace(/[^a-z0-9]/g, '');
|
||||
},
|
||||
services.length > 0 ?
|
||||
E('table', { 'class': 'exp-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', { 'style': 'width: 70px;' }, 'Port'),
|
||||
E('th', {}, 'Service'),
|
||||
E('th', { 'style': 'width: 100px;' }, 'Bind'),
|
||||
E('th', { 'style': 'width: 70px; text-align: center;' }, 'Tor'),
|
||||
E('th', { 'style': 'width: 70px; text-align: center;' }, 'SSL'),
|
||||
E('th', {}, 'Exposure')
|
||||
])
|
||||
]),
|
||||
E('tbody', {}, rows)
|
||||
]) :
|
||||
E('p', { 'class': 'exp-text-muted', 'style': 'text-align: center; padding: 2rem;' },
|
||||
'No listening services detected.')
|
||||
]);
|
||||
},
|
||||
|
||||
handleToggleTor: function(svc, serviceName, wasEnabled, ev) {
|
||||
var self = this;
|
||||
var checkbox = ev.target;
|
||||
var isNowChecked = checkbox.checked;
|
||||
parseBackendPort: function(backend) {
|
||||
if (!backend) return null;
|
||||
var m = backend.match(/:(\d+)$/);
|
||||
return m ? parseInt(m[1]) : null;
|
||||
},
|
||||
|
||||
if (isNowChecked && !wasEnabled) {
|
||||
// Enable Tor - show config dialog
|
||||
ui.showModal('Enable 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.5rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'tor-svc-name',
|
||||
'value': serviceName,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Local Port'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'id': 'tor-local-port',
|
||||
'value': svc.port,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Onion Port (public)'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'id': 'tor-onion-port',
|
||||
'value': '80',
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
checkbox.checked = false;
|
||||
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);
|
||||
handleTorToggle: function(svc, torInfo, ev) {
|
||||
var self = this;
|
||||
var cb = ev.target;
|
||||
|
||||
ui.hideModal();
|
||||
ui.showModal('Creating Hidden Service...', [
|
||||
E('p', { 'class': 'spinning' }, 'Generating .onion address...')
|
||||
]);
|
||||
if (cb.checked && !torInfo) {
|
||||
var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
api.torAdd(name, localPort, onionPort).then(function(res) {
|
||||
ui.hideModal();
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, [
|
||||
'Tor hidden service enabled: ',
|
||||
E('code', {}, res.onion || 'Created')
|
||||
]), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
checkbox.checked = false;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Enable Tor')
|
||||
])
|
||||
]);
|
||||
} else if (!isNowChecked && wasEnabled) {
|
||||
// Disable Tor
|
||||
ui.showModal('Disable 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': function() {
|
||||
checkbox.checked = true;
|
||||
ui.hideModal();
|
||||
}
|
||||
}, 'Cancel'),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-negative',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
api.torRemove(serviceName).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Tor hidden service disabled'), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
checkbox.checked = true;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Disable Tor')
|
||||
])
|
||||
]);
|
||||
}
|
||||
},
|
||||
ui.showModal('Enable Tor Hidden Service', [
|
||||
E('p', {}, 'Create .onion address for ' + (svc.name || svc.process) + ' (port ' + svc.port + ')'),
|
||||
E('div', { 'style': 'margin: 1rem 0;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
|
||||
E('input', {
|
||||
'type': 'text', 'id': 'tor-name', 'value': serviceName,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px; margin-bottom: 12px;'
|
||||
}),
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Onion Port'),
|
||||
E('input', {
|
||||
'type': 'number', 'id': 'tor-onion-port', 'value': '80',
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
|
||||
E('button', { 'class': 'btn', 'click': function() { cb.checked = false; ui.hideModal(); } }, 'Cancel'),
|
||||
E('button', { 'class': 'btn cbi-button-action', 'click': function() {
|
||||
var name = document.getElementById('tor-name').value;
|
||||
var onionPort = parseInt(document.getElementById('tor-onion-port').value) || 80;
|
||||
ui.hideModal();
|
||||
ui.showModal('Creating...', [E('p', { 'class': 'spinning' }, 'Creating Tor hidden service...')]);
|
||||
api.torAdd(name, svc.port, onionPort).then(function(res) {
|
||||
ui.hideModal();
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Tor hidden service created' + (res.onion ? ': ' + res.onion : '')), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
cb.checked = false;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
}).catch(function() { cb.checked = false; ui.hideModal(); });
|
||||
}}, 'Enable')
|
||||
])
|
||||
]);
|
||||
} else if (!cb.checked && torInfo) {
|
||||
ui.showModal('Disable Tor', [
|
||||
E('p', {}, 'Remove hidden service for ' + torInfo.service + '?'),
|
||||
E('p', { 'style': 'color: #e74c3c;' }, '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': function() { cb.checked = true; ui.hideModal(); } }, 'Cancel'),
|
||||
E('button', { 'class': 'btn cbi-button-negative', 'click': function() {
|
||||
ui.hideModal();
|
||||
api.torRemove(torInfo.service).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Tor hidden service removed'), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
cb.checked = true;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
}).catch(function() { cb.checked = true; });
|
||||
}}, 'Remove')
|
||||
])
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
handleToggleSsl: function(svc, serviceName, wasEnabled, sslInfo, ev) {
|
||||
var self = this;
|
||||
var checkbox = ev.target;
|
||||
var isNowChecked = checkbox.checked;
|
||||
handleSslToggle: function(svc, sslInfo, domains, ev) {
|
||||
var self = this;
|
||||
var cb = ev.target;
|
||||
|
||||
if (isNowChecked && !wasEnabled) {
|
||||
// Enable SSL - show config dialog
|
||||
ui.showModal('Enable SSL/HAProxy Backend', [
|
||||
E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process)),
|
||||
E('div', { 'style': 'margin: 1rem 0;' }, [
|
||||
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'ssl-svc-name',
|
||||
'value': serviceName,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Domain (FQDN)'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'ssl-domain',
|
||||
'placeholder': serviceName + '.example.com',
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Backend Port'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'id': 'ssl-port',
|
||||
'value': svc.port,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
checkbox.checked = false;
|
||||
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 (cb.checked && !sslInfo && (!domains || domains.length === 0)) {
|
||||
var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
|
||||
return;
|
||||
}
|
||||
ui.showModal('Enable SSL Backend', [
|
||||
E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process) + ' (port ' + svc.port + ')'),
|
||||
E('div', { 'style': 'margin: 1rem 0;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
|
||||
E('input', {
|
||||
'type': 'text', 'id': 'ssl-name', 'value': serviceName,
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px; margin-bottom: 12px;'
|
||||
}),
|
||||
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Domain (FQDN)'),
|
||||
E('input', {
|
||||
'type': 'text', 'id': 'ssl-domain', 'placeholder': serviceName + '.example.com',
|
||||
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
|
||||
E('button', { 'class': 'btn', 'click': function() { cb.checked = false; ui.hideModal(); } }, 'Cancel'),
|
||||
E('button', { 'class': 'btn cbi-button-action', 'click': function() {
|
||||
var name = document.getElementById('ssl-name').value;
|
||||
var domain = document.getElementById('ssl-domain').value;
|
||||
if (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
|
||||
return;
|
||||
}
|
||||
ui.hideModal();
|
||||
ui.showModal('Configuring...', [E('p', { 'class': 'spinning' }, 'Setting up SSL backend...')]);
|
||||
api.sslAdd(name, domain, svc.port).then(function(res) {
|
||||
ui.hideModal();
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'SSL backend configured for ' + domain), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
cb.checked = false;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
}).catch(function() { cb.checked = false; ui.hideModal(); });
|
||||
}}, 'Enable')
|
||||
])
|
||||
]);
|
||||
} else if (!cb.checked && (sslInfo || (domains && domains.length > 0))) {
|
||||
var backendName = sslInfo ? sslInfo.service : domains[0].backend;
|
||||
var domainName = (sslInfo && sslInfo.domain) ? sslInfo.domain : (domains && domains.length > 0 ? domains[0].domain : '');
|
||||
ui.showModal('Disable SSL Backend', [
|
||||
E('p', {}, 'Remove HAProxy backend for ' + backendName + '?'),
|
||||
domainName ? E('p', { 'style': 'color: #8892b0;' }, 'Domain: ' + domainName) : null,
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
||||
E('button', { 'class': 'btn', 'click': function() { cb.checked = true; ui.hideModal(); } }, 'Cancel'),
|
||||
E('button', { 'class': 'btn cbi-button-negative', 'click': function() {
|
||||
ui.hideModal();
|
||||
api.sslRemove(backendName).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'SSL backend removed'), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
cb.checked = true;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
}).catch(function() { cb.checked = true; });
|
||||
}}, 'Remove')
|
||||
])
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
ui.hideModal();
|
||||
api.sslAdd(name, domain, port).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'SSL backend configured for ' + domain), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
checkbox.checked = false;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Enable SSL')
|
||||
])
|
||||
]);
|
||||
} else if (!isNowChecked && wasEnabled) {
|
||||
// Disable SSL
|
||||
var domain = sslInfo ? sslInfo.domain : serviceName;
|
||||
ui.showModal('Disable SSL Backend', [
|
||||
E('p', {}, 'Remove HAProxy backend for ' + serviceName + '?'),
|
||||
sslInfo && sslInfo.domain ? E('p', { 'style': 'color: #8892b0;' }, 'Domain: ' + sslInfo.domain) : null,
|
||||
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
checkbox.checked = true;
|
||||
ui.hideModal();
|
||||
}
|
||||
}, 'Cancel'),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-negative',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
api.sslRemove(serviceName).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'SSL backend disabled'), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
checkbox.checked = true;
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Disable SSL')
|
||||
])
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
|
||||
@ -41,6 +41,8 @@ case "$1" in
|
||||
json_add_object "ssl_remove"
|
||||
json_add_string "service" "string"
|
||||
json_close_object
|
||||
json_add_object "vhost_list"
|
||||
json_close_object
|
||||
json_dump
|
||||
;;
|
||||
|
||||
@ -49,6 +51,9 @@ case "$1" in
|
||||
scan)
|
||||
# Scan listening services - use temp file to avoid subshell issues
|
||||
TMP_SVC="/tmp/exposure_scan_$$"
|
||||
TMP_NAMES="/tmp/exposure_names_$$"
|
||||
> "$TMP_NAMES"
|
||||
|
||||
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
|
||||
split($4, a, ":")
|
||||
port = a[length(a)]
|
||||
@ -60,6 +65,47 @@ case "$1" in
|
||||
}
|
||||
}' | sort -n > "$TMP_SVC"
|
||||
|
||||
# Build port->name enrichment from component configs
|
||||
|
||||
# uhttpd instances
|
||||
for _s in $(uci show uhttpd 2>/dev/null | grep "=uhttpd$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
_listen=$(uci -q get "uhttpd.${_s}.listen_http")
|
||||
[ -z "$_listen" ] && continue
|
||||
_p=$(echo "$_listen" | grep -o '[0-9]*$')
|
||||
case "$_s" in
|
||||
main) echo "$_p|LuCI" >> "$TMP_NAMES" ;;
|
||||
acme) echo "$_p|ACME Challenge" >> "$TMP_NAMES" ;;
|
||||
metablog_site_*) echo "$_p|Metablog: $(echo "$_s" | sed 's/^metablog_site_//')" >> "$TMP_NAMES" ;;
|
||||
p2p_api) echo "$_p|P2P API" >> "$TMP_NAMES" ;;
|
||||
*) echo "$_p|uhttpd: $_s" >> "$TMP_NAMES" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Streamlit instances
|
||||
for _s in $(uci show streamlit 2>/dev/null | grep "\.port=" | cut -d'.' -f2); do
|
||||
_p=$(uci -q get "streamlit.${_s}.port")
|
||||
_n=$(uci -q get "streamlit.${_s}.name")
|
||||
[ -n "$_p" ] && echo "$_p|Streamlit: ${_n:-$_s}" >> "$TMP_NAMES"
|
||||
done
|
||||
|
||||
# Docker containers
|
||||
docker ps --format '{{.Ports}}|{{.Names}}' 2>/dev/null | while IFS='|' read _ports _cname; do
|
||||
[ -z "$_cname" ] && continue
|
||||
echo "$_ports" | tr ',' '\n' | while read _bind; do
|
||||
_hp=$(echo "$_bind" | sed -n 's/.*:\([0-9]*\)->.*/\1/p')
|
||||
[ -n "$_hp" ] && echo "$_hp|Docker: $_cname" >> "$TMP_NAMES"
|
||||
done
|
||||
done
|
||||
|
||||
# Glances
|
||||
_gp=$(uci -q get glances.main.web_port)
|
||||
[ -n "$_gp" ] && echo "$_gp|Glances" >> "$TMP_NAMES"
|
||||
|
||||
# Known services by port
|
||||
echo "9000|Lyrion" >> "$TMP_NAMES"
|
||||
echo "3483|Lyrion Discovery" >> "$TMP_NAMES"
|
||||
echo "9090|Lyrion CLI" >> "$TMP_NAMES"
|
||||
|
||||
json_init
|
||||
json_add_array "services"
|
||||
|
||||
@ -73,19 +119,23 @@ case "$1" in
|
||||
*) external=1 ;;
|
||||
esac
|
||||
|
||||
name="$proc"
|
||||
case "$proc" in
|
||||
sshd|dropbear) name="SSH" ;;
|
||||
dnsmasq) name="DNS" ;;
|
||||
haproxy) name="HAProxy" ;;
|
||||
uhttpd) name="LuCI" ;;
|
||||
gitea) name="Gitea" ;;
|
||||
netifyd) name="Netifyd" ;;
|
||||
tor) name="Tor" ;;
|
||||
python*) name="Python App" ;;
|
||||
streamlit) name="Streamlit" ;;
|
||||
hexo|node) name="HexoJS" ;;
|
||||
esac
|
||||
# Try enriched name first, fallback to process-based mapping
|
||||
name=$(grep "^${port}|" "$TMP_NAMES" | head -1 | cut -d'|' -f2)
|
||||
if [ -z "$name" ]; then
|
||||
case "$proc" in
|
||||
sshd|dropbear) name="SSH" ;;
|
||||
dnsmasq) name="DNS" ;;
|
||||
haproxy) name="HAProxy" ;;
|
||||
uhttpd) name="LuCI" ;;
|
||||
gitea) name="Gitea" ;;
|
||||
netifyd) name="Netifyd" ;;
|
||||
tor) name="Tor" ;;
|
||||
python*) name="Python App" ;;
|
||||
streamlit) name="Streamlit" ;;
|
||||
hexo|node) name="HexoJS" ;;
|
||||
*) name="$proc" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
json_add_object ""
|
||||
json_add_int "port" "$port"
|
||||
@ -96,7 +146,7 @@ case "$1" in
|
||||
json_close_object
|
||||
done < "$TMP_SVC"
|
||||
|
||||
rm -f "$TMP_SVC"
|
||||
rm -f "$TMP_SVC" "$TMP_NAMES"
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
@ -412,6 +462,83 @@ case "$1" in
|
||||
json_dump
|
||||
;;
|
||||
|
||||
vhost_list)
|
||||
json_init
|
||||
|
||||
# HAProxy vhosts (domain -> backend with resolved port)
|
||||
json_add_array "haproxy"
|
||||
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
domain=$(uci -q get "haproxy.${vhost}.domain")
|
||||
backend=$(uci -q get "haproxy.${vhost}.backend")
|
||||
enabled=$(uci -q get "haproxy.${vhost}.enabled")
|
||||
ssl=$(uci -q get "haproxy.${vhost}.ssl")
|
||||
acme=$(uci -q get "haproxy.${vhost}.acme")
|
||||
|
||||
[ -z "$domain" ] && continue
|
||||
|
||||
# Check for original_backend (when mitmproxy is intercepting)
|
||||
original_backend=$(uci -q get "haproxy.${vhost}.original_backend")
|
||||
resolve_backend="${original_backend:-$backend}"
|
||||
|
||||
# Resolve backend port from the target backend
|
||||
backend_port=""
|
||||
if [ -n "$resolve_backend" ]; then
|
||||
# Try inline server option: 'name IP:PORT check'
|
||||
server_line=$(uci -q get "haproxy.${resolve_backend}.server" 2>/dev/null)
|
||||
if [ -n "$server_line" ]; then
|
||||
backend_port=$(echo "$server_line" | awk '{print $2}' | grep -o ':[0-9]*' | tr -d ':')
|
||||
fi
|
||||
# Try server sections referencing this backend
|
||||
if [ -z "$backend_port" ]; then
|
||||
for srv in $(uci show haproxy 2>/dev/null | grep "=server$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
srv_backend=$(uci -q get "haproxy.${srv}.backend")
|
||||
if [ "$srv_backend" = "$resolve_backend" ]; then
|
||||
backend_port=$(uci -q get "haproxy.${srv}.port")
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "id" "$vhost"
|
||||
json_add_string "domain" "$domain"
|
||||
json_add_string "backend" "${resolve_backend:-${backend:-}}"
|
||||
json_add_int "backend_port" "${backend_port:-0}"
|
||||
json_add_boolean "ssl" "${ssl:-0}"
|
||||
json_add_boolean "acme" "${acme:-0}"
|
||||
json_add_boolean "enabled" "${enabled:-0}"
|
||||
json_close_object
|
||||
done
|
||||
json_close_array
|
||||
|
||||
# uhttpd vhosts (non-main instances)
|
||||
json_add_array "uhttpd"
|
||||
for section in $(uci show uhttpd 2>/dev/null | grep "=uhttpd$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
[ "$section" = "main" ] && continue
|
||||
[ "$section" = "acme" ] && continue
|
||||
|
||||
listen=$(uci -q get "uhttpd.${section}.listen_http")
|
||||
home=$(uci -q get "uhttpd.${section}.home")
|
||||
[ -z "$listen" ] && continue
|
||||
|
||||
port=$(echo "$listen" | grep -o '[0-9]*$')
|
||||
|
||||
# Derive friendly name from section id
|
||||
fname=$(echo "$section" | sed 's/^metablog_site_//' | sed 's/_/ /g')
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "id" "$section"
|
||||
json_add_int "port" "${port:-0}"
|
||||
json_add_string "name" "$fname"
|
||||
json_add_string "home" "${home:-}"
|
||||
json_close_object
|
||||
done
|
||||
json_close_array
|
||||
|
||||
json_dump
|
||||
;;
|
||||
|
||||
*)
|
||||
json_init
|
||||
json_add_boolean "error" 1
|
||||
|
||||
@ -4,34 +4,10 @@
|
||||
"order": 35,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "exposure/overview"
|
||||
"path": "exposure/services"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-exposure"]
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/exposure/services": {
|
||||
"title": "Services",
|
||||
"order": 1,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "exposure/services"
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/exposure/tor": {
|
||||
"title": "Tor Hidden",
|
||||
"order": 2,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "exposure/tor"
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/exposure/ssl": {
|
||||
"title": "SSL Proxy",
|
||||
"order": 3,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "exposure/ssl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"description": "Grant access to SecuBox Service Exposure Manager",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config"]
|
||||
"luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config", "vhost_list"]
|
||||
},
|
||||
"uci": ["secubox-exposure"]
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user