Merge branch 'release/v0.15.0'
This commit is contained in:
commit
5cd6c128f3
34
package/secubox/luci-app-exposure/Makefile
Normal file
34
package/secubox/luci-app-exposure/Makefile
Normal file
@ -0,0 +1,34 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-exposure
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=3
|
||||
|
||||
PKG_MAINTAINER:=SecuBox Team <contact@secubox.dev>
|
||||
PKG_LICENSE:=MIT
|
||||
|
||||
LUCI_TITLE:=LuCI SecuBox Service Exposure Manager
|
||||
LUCI_DEPENDS:=+luci-base +secubox-app-exposure
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/luci-app-exposure/install
|
||||
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
||||
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.exposure $(1)/usr/libexec/rpcd/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
|
||||
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-exposure.json $(1)/usr/share/rpcd/acl.d/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-exposure.json $(1)/usr/share/luci/menu.d/
|
||||
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/exposure
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/exposure/*.js $(1)/www/luci-static/resources/view/exposure/
|
||||
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/exposure
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/exposure/*.js $(1)/www/luci-static/resources/exposure/
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/exposure/*.css $(1)/www/luci-static/resources/exposure/
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-exposure))
|
||||
@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
'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); }
|
||||
});
|
||||
@ -0,0 +1,869 @@
|
||||
/* SecuBox Service Exposure Manager - Dashboard Styles */
|
||||
/* Unified theme matching SecuBox HAProxy dashboard */
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
.exposure-dashboard {
|
||||
padding: 0;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Page 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);
|
||||
}
|
||||
|
||||
.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 */
|
||||
.exp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.exp-table th,
|
||||
.exp-table td {
|
||||
padding: 12px 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.exp-table td {
|
||||
color: var(--exp-text-primary);
|
||||
}
|
||||
|
||||
.exp-table tr:hover td {
|
||||
background: rgba(100, 255, 218, 0.03);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.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);
|
||||
}
|
||||
|
||||
.exp-badge-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);
|
||||
}
|
||||
|
||||
/* Monospace text */
|
||||
.exp-mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.exp-toast {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle switches (from services.js) */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* === 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); }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,460 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'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()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var scanResult = data[0] || {};
|
||||
var configResult = data[1] || {};
|
||||
var torResult = data[2] || {};
|
||||
var sslResult = 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;
|
||||
|
||||
// Build lookup maps for current exposure status
|
||||
var torByService = {};
|
||||
torServices.forEach(function(t) {
|
||||
torByService[t.service] = t;
|
||||
});
|
||||
|
||||
var sslByService = {};
|
||||
sslBackends.forEach(function(s) {
|
||||
sslByService[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);
|
||||
}
|
||||
|
||||
// Filter to only external services (exposable)
|
||||
var exposableServices = services.filter(function(svc) {
|
||||
return svc.external;
|
||||
});
|
||||
|
||||
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)'),
|
||||
|
||||
// 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')
|
||||
])
|
||||
]),
|
||||
|
||||
// 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')
|
||||
]),
|
||||
|
||||
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;
|
||||
|
||||
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')
|
||||
]),
|
||||
|
||||
// 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 view;
|
||||
},
|
||||
|
||||
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, '');
|
||||
},
|
||||
|
||||
handleToggleTor: function(svc, serviceName, wasEnabled, ev) {
|
||||
var self = this;
|
||||
var checkbox = ev.target;
|
||||
var isNowChecked = checkbox.checked;
|
||||
|
||||
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);
|
||||
|
||||
ui.hideModal();
|
||||
ui.showModal('Creating Hidden Service...', [
|
||||
E('p', { 'class': 'spinning' }, 'Generating .onion address...')
|
||||
]);
|
||||
|
||||
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')
|
||||
])
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
handleToggleSsl: function(svc, serviceName, wasEnabled, sslInfo, ev) {
|
||||
var self = this;
|
||||
var checkbox = ev.target;
|
||||
var isNowChecked = checkbox.checked;
|
||||
|
||||
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 (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require exposure/api as api';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
api.sslList(),
|
||||
api.scan()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var sslResult = data[0] || {};
|
||||
var scanResult = data[1] || {};
|
||||
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
|
||||
var allServices = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
|
||||
var self = this;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
var view = E('div', { 'class': 'exposure-dashboard' }, [
|
||||
E('h2', {}, '\ud83d\udd12 HAProxy SSL Backends'),
|
||||
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
|
||||
'Configure HTTPS reverse proxy for your services'),
|
||||
|
||||
// Add new backend form
|
||||
E('div', { 'class': 'exposure-section' }, [
|
||||
E('div', { 'class': 'exposure-section-header' }, [
|
||||
E('div', { 'class': 'exposure-section-title' }, [
|
||||
E('span', { 'class': 'icon' }, '\u2795'),
|
||||
'Add SSL Backend'
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form' }, [
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Service'),
|
||||
E('select', { 'id': 'new-ssl-service' },
|
||||
[E('option', { 'value': '' }, '-- Select --')].concat(
|
||||
allServices.filter(function(s) { return s.external; }).map(function(s) {
|
||||
var name = s.name || s.process;
|
||||
return E('option', {
|
||||
'value': s.process,
|
||||
'data-port': s.port,
|
||||
'data-name': name.toLowerCase().replace(/\s+/g, '')
|
||||
}, name + ' (:' + s.port + ')');
|
||||
})
|
||||
)
|
||||
)
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Domain'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'new-ssl-domain',
|
||||
'placeholder': 'service.example.com'
|
||||
})
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Backend Port'),
|
||||
E('input', { 'type': 'number', 'id': 'new-ssl-port', 'placeholder': '3000' })
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'btn-action btn-primary',
|
||||
'click': ui.createHandlerFn(self, 'handleAdd')
|
||||
}, 'Add Backend')
|
||||
])
|
||||
]),
|
||||
|
||||
// Info box
|
||||
E('div', {
|
||||
'class': 'exposure-section',
|
||||
'style': 'background: rgba(0, 212, 255, 0.1); border-color: #00d4ff;'
|
||||
}, [
|
||||
E('p', { 'style': 'margin: 0; color: #ccd6f6;' }, [
|
||||
E('strong', {}, '\u2139\ufe0f SSL Certificate: '),
|
||||
'After adding a backend, upload the SSL certificate to ',
|
||||
E('code', {}, '/srv/lxc/haproxy/rootfs/etc/haproxy/certs/'),
|
||||
'. The certificate file should be named ',
|
||||
E('code', {}, 'domain.pem'),
|
||||
' and contain both the certificate and private key.'
|
||||
])
|
||||
]),
|
||||
|
||||
// Existing backends
|
||||
E('div', { 'class': 'exposure-section' }, [
|
||||
E('div', { 'class': 'exposure-section-header' }, [
|
||||
E('div', { 'class': 'exposure-section-title' }, [
|
||||
E('span', { 'class': 'icon' }, '\ud83d\udd12'),
|
||||
'Active SSL Backends (' + sslBackends.length + ')'
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'btn-action btn-primary',
|
||||
'click': function() { location.reload(); }
|
||||
}, 'Refresh')
|
||||
]),
|
||||
|
||||
sslBackends.length > 0 ?
|
||||
E('table', { 'class': 'exposure-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, 'Service'),
|
||||
E('th', {}, 'Domain'),
|
||||
E('th', {}, 'Backend'),
|
||||
E('th', {}, 'Actions')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
sslBackends.map(function(b) {
|
||||
return E('tr', {}, [
|
||||
E('td', { 'style': 'font-weight: 600;' }, b.service),
|
||||
E('td', {}, [
|
||||
E('a', {
|
||||
'href': 'https://' + b.domain,
|
||||
'target': '_blank',
|
||||
'style': 'color: #00d4ff;'
|
||||
}, b.domain),
|
||||
E('span', { 'style': 'margin-left: 0.5rem;' }, '\ud83d\udd17')
|
||||
]),
|
||||
E('td', { 'style': 'font-family: monospace;' }, b.backend || 'N/A'),
|
||||
E('td', {}, [
|
||||
E('button', {
|
||||
'class': 'btn-action btn-danger',
|
||||
'click': ui.createHandlerFn(self, 'handleRemove', b.service)
|
||||
}, 'Remove')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
]) :
|
||||
E('div', { 'class': 'exposure-empty' }, [
|
||||
E('div', { 'class': 'icon' }, '\ud83d\udd12'),
|
||||
E('p', {}, 'No SSL backends configured'),
|
||||
E('p', { 'style': 'font-size: 0.85rem;' }, 'Select a service above to add HTTPS access')
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
// Wire up service selector
|
||||
setTimeout(function() {
|
||||
var sel = document.getElementById('new-ssl-service');
|
||||
var portInput = document.getElementById('new-ssl-port');
|
||||
var domainInput = document.getElementById('new-ssl-domain');
|
||||
if (sel && portInput) {
|
||||
sel.addEventListener('change', function() {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
portInput.value = opt.dataset.port || '';
|
||||
if (opt.dataset.name) {
|
||||
domainInput.placeholder = opt.dataset.name + '.example.com';
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
handleAdd: function(ev) {
|
||||
var service = document.getElementById('new-ssl-service').value;
|
||||
var domain = document.getElementById('new-ssl-domain').value;
|
||||
var port = parseInt(document.getElementById('new-ssl-port').value);
|
||||
|
||||
if (!service) {
|
||||
ui.addNotification(null, E('p', {}, 'Please select a service'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
ui.addNotification(null, E('p', {}, 'Please enter a domain'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
ui.addNotification(null, E('p', {}, 'Please specify the backend port'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
api.sslAdd(service, domain, port).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, [
|
||||
'SSL backend configured for ',
|
||||
E('strong', {}, domain),
|
||||
E('br'),
|
||||
'Remember to upload the SSL certificate!'
|
||||
]), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
handleRemove: function(service, ev) {
|
||||
if (!confirm('Remove SSL backend for ' + service + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.sslRemove(service).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'SSL backend removed'), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require exposure/api as api';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
api.torList(),
|
||||
api.scan()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var torResult = data[0] || {};
|
||||
var scanResult = data[1] || {};
|
||||
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
|
||||
var allServices = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
|
||||
var self = this;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
var view = E('div', { 'class': 'exposure-dashboard' }, [
|
||||
E('h2', {}, '\ud83e\uddc5 Tor Hidden Services'),
|
||||
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
|
||||
'Expose services on the Tor network with .onion addresses'),
|
||||
|
||||
// Add new service form
|
||||
E('div', { 'class': 'exposure-section' }, [
|
||||
E('div', { 'class': 'exposure-section-header' }, [
|
||||
E('div', { 'class': 'exposure-section-title' }, [
|
||||
E('span', { 'class': 'icon' }, '\u2795'),
|
||||
'Add Hidden Service'
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form' }, [
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Service'),
|
||||
E('select', { 'id': 'new-tor-service' },
|
||||
[E('option', { 'value': '' }, '-- Select --')].concat(
|
||||
allServices.filter(function(s) { return s.external; }).map(function(s) {
|
||||
var name = s.name || s.process;
|
||||
return E('option', { 'value': s.process, 'data-port': s.port },
|
||||
name + ' (:' + s.port + ')');
|
||||
})
|
||||
)
|
||||
)
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Local Port'),
|
||||
E('input', { 'type': 'number', 'id': 'new-tor-port', 'placeholder': '3000' })
|
||||
]),
|
||||
E('div', { 'class': 'exposure-form-group' }, [
|
||||
E('label', {}, 'Onion Port'),
|
||||
E('input', { 'type': 'number', 'id': 'new-tor-onion-port', 'value': '80' })
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'btn-action btn-primary',
|
||||
'click': ui.createHandlerFn(self, 'handleAdd')
|
||||
}, 'Create .onion')
|
||||
])
|
||||
]),
|
||||
|
||||
// Existing services
|
||||
E('div', { 'class': 'exposure-section' }, [
|
||||
E('div', { 'class': 'exposure-section-header' }, [
|
||||
E('div', { 'class': 'exposure-section-title' }, [
|
||||
E('span', { 'class': 'icon' }, '\ud83e\uddc5'),
|
||||
'Active Hidden Services (' + torServices.length + ')'
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'btn-action btn-primary',
|
||||
'click': function() { location.reload(); }
|
||||
}, 'Refresh')
|
||||
]),
|
||||
|
||||
torServices.length > 0 ?
|
||||
E('table', { 'class': 'exposure-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, 'Service'),
|
||||
E('th', {}, 'Onion Address'),
|
||||
E('th', {}, 'Port'),
|
||||
E('th', {}, 'Backend'),
|
||||
E('th', {}, 'Actions')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
torServices.map(function(svc) {
|
||||
return E('tr', {}, [
|
||||
E('td', { 'style': 'font-weight: 600;' }, svc.service),
|
||||
E('td', {}, [
|
||||
E('code', { 'class': 'onion-address' }, svc.onion),
|
||||
E('button', {
|
||||
'class': 'btn-action',
|
||||
'style': 'margin-left: 0.5rem; padding: 0.25rem 0.5rem;',
|
||||
'click': function() {
|
||||
navigator.clipboard.writeText(svc.onion);
|
||||
ui.addNotification(null, E('p', {}, 'Copied to clipboard'), 'info');
|
||||
}
|
||||
}, '\ud83d\udccb')
|
||||
]),
|
||||
E('td', {}, svc.port || '80'),
|
||||
E('td', { 'style': 'font-family: monospace;' }, svc.backend || 'N/A'),
|
||||
E('td', {}, [
|
||||
E('button', {
|
||||
'class': 'btn-action btn-danger',
|
||||
'click': ui.createHandlerFn(self, 'handleRemove', svc.service)
|
||||
}, 'Remove')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
]) :
|
||||
E('div', { 'class': 'exposure-empty' }, [
|
||||
E('div', { 'class': 'icon' }, '\ud83e\uddc5'),
|
||||
E('p', {}, 'No Tor hidden services configured'),
|
||||
E('p', { 'style': 'font-size: 0.85rem;' }, 'Select a service above to create a .onion address')
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
// Wire up service selector
|
||||
setTimeout(function() {
|
||||
var sel = document.getElementById('new-tor-service');
|
||||
var portInput = document.getElementById('new-tor-port');
|
||||
if (sel && portInput) {
|
||||
sel.addEventListener('change', function() {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
portInput.value = opt.dataset.port || '';
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
handleAdd: function(ev) {
|
||||
var service = document.getElementById('new-tor-service').value;
|
||||
var port = parseInt(document.getElementById('new-tor-port').value);
|
||||
var onionPort = parseInt(document.getElementById('new-tor-onion-port').value) || 80;
|
||||
|
||||
if (!service) {
|
||||
ui.addNotification(null, E('p', {}, 'Please select a service'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
ui.addNotification(null, E('p', {}, 'Please specify the local port'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.showModal('Creating Hidden Service...', [
|
||||
E('p', { 'class': 'spinning' }, 'Please wait, generating .onion address (this may take a moment)...')
|
||||
]);
|
||||
|
||||
api.torAdd(service, port, onionPort).then(function(res) {
|
||||
ui.hideModal();
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, [
|
||||
'Hidden service created! ',
|
||||
E('br'),
|
||||
E('code', { 'style': 'word-break: break-all;' }, res.onion || 'Refresh to see address')
|
||||
]), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
handleRemove: function(service, ev) {
|
||||
if (!confirm('Remove hidden service for ' + service + '?\n\nThe .onion address will be permanently lost.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.torRemove(service).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Hidden service removed'), 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
423
package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure
Executable file
423
package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure
Executable file
@ -0,0 +1,423 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# RPCD backend for SecuBox Service Exposure Manager
|
||||
#
|
||||
|
||||
. /usr/share/libubox/jshn.sh
|
||||
. /lib/functions.sh
|
||||
|
||||
case "$1" in
|
||||
list)
|
||||
json_init
|
||||
json_add_object "scan"
|
||||
json_close_object
|
||||
json_add_object "conflicts"
|
||||
json_close_object
|
||||
json_add_object "status"
|
||||
json_close_object
|
||||
json_add_object "tor_list"
|
||||
json_close_object
|
||||
json_add_object "ssl_list"
|
||||
json_close_object
|
||||
json_add_object "get_config"
|
||||
json_close_object
|
||||
json_add_object "fix_port"
|
||||
json_add_string "service" "string"
|
||||
json_add_int "port" "integer"
|
||||
json_close_object
|
||||
json_add_object "tor_add"
|
||||
json_add_string "service" "string"
|
||||
json_add_int "local_port" "integer"
|
||||
json_add_int "onion_port" "integer"
|
||||
json_close_object
|
||||
json_add_object "tor_remove"
|
||||
json_add_string "service" "string"
|
||||
json_close_object
|
||||
json_add_object "ssl_add"
|
||||
json_add_string "service" "string"
|
||||
json_add_string "domain" "string"
|
||||
json_add_int "local_port" "integer"
|
||||
json_close_object
|
||||
json_add_object "ssl_remove"
|
||||
json_add_string "service" "string"
|
||||
json_close_object
|
||||
json_dump
|
||||
;;
|
||||
|
||||
call)
|
||||
case "$2" in
|
||||
scan)
|
||||
# Scan listening services - use temp file to avoid subshell issues
|
||||
TMP_SVC="/tmp/exposure_scan_$$"
|
||||
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
|
||||
split($4, a, ":")
|
||||
port = a[length(a)]
|
||||
if (!seen[port]++) {
|
||||
split($7, p, "/")
|
||||
proc = p[2]
|
||||
if (proc == "") proc = "unknown"
|
||||
print port, $4, proc
|
||||
}
|
||||
}' | sort -n > "$TMP_SVC"
|
||||
|
||||
json_init
|
||||
json_add_array "services"
|
||||
|
||||
while read port addr proc; do
|
||||
[ -z "$port" ] && continue
|
||||
|
||||
external=0
|
||||
case "$addr" in
|
||||
*0.0.0.0*|*::*) external=1 ;;
|
||||
*127.0.0.1*|*::1*) external=0 ;;
|
||||
*) 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
|
||||
|
||||
json_add_object ""
|
||||
json_add_int "port" "$port"
|
||||
json_add_string "address" "$addr"
|
||||
json_add_string "process" "$proc"
|
||||
json_add_string "name" "$name"
|
||||
json_add_boolean "external" "$external"
|
||||
json_close_object
|
||||
done < "$TMP_SVC"
|
||||
|
||||
rm -f "$TMP_SVC"
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
|
||||
status)
|
||||
json_init
|
||||
|
||||
total=$(netstat -tlnp 2>/dev/null | grep LISTEN | awk '{split($4,a,":"); print a[length(a)]}' | sort -u | wc -l)
|
||||
external=$(netstat -tlnp 2>/dev/null | grep LISTEN | grep -E "0\.0\.0\.0|::" | awk '{split($4,a,":"); print a[length(a)]}' | sort -u | wc -l)
|
||||
|
||||
json_add_object "services"
|
||||
json_add_int "total" "$total"
|
||||
json_add_int "external" "$external"
|
||||
json_close_object
|
||||
|
||||
# Tor hidden services
|
||||
TOR_DIR="/var/lib/tor/hidden_services"
|
||||
tor_count=0
|
||||
[ -d "$TOR_DIR" ] && tor_count=$(ls -1d "$TOR_DIR"/*/ 2>/dev/null | wc -l)
|
||||
|
||||
json_add_object "tor"
|
||||
json_add_int "count" "$tor_count"
|
||||
json_add_array "services"
|
||||
if [ -d "$TOR_DIR" ]; then
|
||||
for dir in "$TOR_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
svc=$(basename "$dir")
|
||||
onion=""
|
||||
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
|
||||
if [ -n "$onion" ]; then
|
||||
json_add_object ""
|
||||
json_add_string "service" "$svc"
|
||||
json_add_string "onion" "$onion"
|
||||
json_close_object
|
||||
fi
|
||||
done
|
||||
fi
|
||||
json_close_array
|
||||
json_close_object
|
||||
|
||||
# HAProxy SSL backends - read from UCI config
|
||||
TMP_SSL="/tmp/exposure_ssl_$$"
|
||||
ssl_count=0
|
||||
|
||||
# Get vhosts from UCI (enabled ones with domains)
|
||||
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")
|
||||
[ "$enabled" != "1" ] && continue
|
||||
[ -z "$domain" ] && continue
|
||||
echo "${backend:-$vhost}|${domain}" >> "$TMP_SSL"
|
||||
ssl_count=$((ssl_count + 1))
|
||||
done
|
||||
|
||||
json_add_object "ssl"
|
||||
json_add_int "count" "$ssl_count"
|
||||
json_add_array "backends"
|
||||
if [ -f "$TMP_SSL" ]; then
|
||||
while IFS='|' read backend domain; do
|
||||
[ -z "$backend" ] && continue
|
||||
json_add_object ""
|
||||
json_add_string "service" "$backend"
|
||||
json_add_string "domain" "$domain"
|
||||
json_close_object
|
||||
done < "$TMP_SSL"
|
||||
rm -f "$TMP_SSL"
|
||||
fi
|
||||
json_close_array
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
;;
|
||||
|
||||
tor_list)
|
||||
TOR_DIR="/var/lib/tor/hidden_services"
|
||||
TOR_CONFIG="/etc/tor/torrc"
|
||||
|
||||
json_init
|
||||
json_add_array "services"
|
||||
|
||||
if [ -d "$TOR_DIR" ]; then
|
||||
for dir in "$TOR_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
svc=$(basename "$dir")
|
||||
onion=""
|
||||
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
|
||||
|
||||
port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
|
||||
backend=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $3}')
|
||||
|
||||
if [ -n "$onion" ]; then
|
||||
json_add_object ""
|
||||
json_add_string "service" "$svc"
|
||||
json_add_string "onion" "$onion"
|
||||
json_add_string "port" "${port:-80}"
|
||||
json_add_string "backend" "${backend:-N/A}"
|
||||
json_close_object
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
|
||||
ssl_list)
|
||||
TMP_SSLLIST="/tmp/exposure_ssllist_$$"
|
||||
> "$TMP_SSLLIST"
|
||||
|
||||
# Read from HAProxy UCI config (vhosts with their backends)
|
||||
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")
|
||||
|
||||
[ "$enabled" != "1" ] && continue
|
||||
[ -z "$domain" ] && continue
|
||||
|
||||
# Get server address from backend config
|
||||
server=""
|
||||
if [ -n "$backend" ]; then
|
||||
server=$(uci -q get "haproxy.${backend}.server" 2>/dev/null | head -1 | awk '{print $2}')
|
||||
fi
|
||||
|
||||
echo "${backend:-$vhost}|${domain}|${server:-N/A}" >> "$TMP_SSLLIST"
|
||||
done
|
||||
|
||||
json_init
|
||||
json_add_array "backends"
|
||||
|
||||
if [ -s "$TMP_SSLLIST" ]; then
|
||||
while IFS='|' read service domain server; do
|
||||
[ -z "$service" ] && continue
|
||||
json_add_object ""
|
||||
json_add_string "service" "$service"
|
||||
json_add_string "domain" "$domain"
|
||||
json_add_string "backend" "$server"
|
||||
json_close_object
|
||||
done < "$TMP_SSLLIST"
|
||||
fi
|
||||
rm -f "$TMP_SSLLIST"
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
|
||||
get_config)
|
||||
json_init
|
||||
json_add_array "known_services"
|
||||
|
||||
config_load "secubox-exposure"
|
||||
|
||||
get_known() {
|
||||
local section="$1"
|
||||
local default_port config_path category
|
||||
|
||||
config_get default_port "$section" default_port
|
||||
config_get config_path "$section" config_path
|
||||
config_get category "$section" category "other"
|
||||
|
||||
actual_port=""
|
||||
if [ -n "$config_path" ]; then
|
||||
actual_port=$(uci -q get "$config_path" 2>/dev/null)
|
||||
fi
|
||||
[ -z "$actual_port" ] && actual_port="$default_port"
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "id" "$section"
|
||||
json_add_int "default_port" "${default_port:-0}"
|
||||
json_add_int "actual_port" "${actual_port:-0}"
|
||||
json_add_string "config_path" "$config_path"
|
||||
json_add_string "category" "$category"
|
||||
json_close_object
|
||||
}
|
||||
config_foreach get_known known
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
|
||||
conflicts)
|
||||
json_init
|
||||
json_add_array "conflicts"
|
||||
json_close_array
|
||||
json_dump
|
||||
;;
|
||||
|
||||
fix_port)
|
||||
read -r input
|
||||
service=$(echo "$input" | jsonfilter -e '@.service')
|
||||
port=$(echo "$input" | jsonfilter -e '@.port')
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Service name required"
|
||||
json_dump
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(/usr/sbin/secubox-exposure fix-port "$service" "$port" 2>&1)
|
||||
json_init
|
||||
if [ $? -eq 0 ]; then
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "$result"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
;;
|
||||
|
||||
tor_add)
|
||||
read -r input
|
||||
service=$(echo "$input" | jsonfilter -e '@.service')
|
||||
local_port=$(echo "$input" | jsonfilter -e '@.local_port')
|
||||
onion_port=$(echo "$input" | jsonfilter -e '@.onion_port')
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Service name required"
|
||||
json_dump
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(/usr/sbin/secubox-exposure tor add "$service" "$local_port" "$onion_port" 2>&1)
|
||||
json_init
|
||||
if echo "$result" | grep -q "Hidden service created"; then
|
||||
onion=$(echo "$result" | grep "Onion:" | awk '{print $2}')
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "onion" "$onion"
|
||||
json_add_string "message" "Hidden service created"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
;;
|
||||
|
||||
tor_remove)
|
||||
read -r input
|
||||
service=$(echo "$input" | jsonfilter -e '@.service')
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Service name required"
|
||||
json_dump
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(/usr/sbin/secubox-exposure tor remove "$service" 2>&1)
|
||||
json_init
|
||||
if echo "$result" | grep -q "removed"; then
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Hidden service removed"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
;;
|
||||
|
||||
ssl_add)
|
||||
read -r input
|
||||
service=$(echo "$input" | jsonfilter -e '@.service')
|
||||
domain=$(echo "$input" | jsonfilter -e '@.domain')
|
||||
local_port=$(echo "$input" | jsonfilter -e '@.local_port')
|
||||
|
||||
if [ -z "$service" ] || [ -z "$domain" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Service and domain required"
|
||||
json_dump
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(/usr/sbin/secubox-exposure ssl add "$service" "$domain" "$local_port" 2>&1)
|
||||
json_init
|
||||
if echo "$result" | grep -q "configured"; then
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "SSL backend configured"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
;;
|
||||
|
||||
ssl_remove)
|
||||
read -r input
|
||||
service=$(echo "$input" | jsonfilter -e '@.service')
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Service name required"
|
||||
json_dump
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(/usr/sbin/secubox-exposure ssl remove "$service" 2>&1)
|
||||
json_init
|
||||
if echo "$result" | grep -q "removed"; then
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "SSL backend removed"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
;;
|
||||
|
||||
*)
|
||||
json_init
|
||||
json_add_boolean "error" 1
|
||||
json_add_string "message" "Unknown method: $2"
|
||||
json_dump
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
@ -0,0 +1,37 @@
|
||||
{
|
||||
"admin/secubox/network/exposure": {
|
||||
"title": "Service Exposure",
|
||||
"order": 35,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "exposure/overview"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
{
|
||||
"luci-app-exposure": {
|
||||
"description": "Grant access to SecuBox Service Exposure Manager",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config"]
|
||||
},
|
||||
"uci": ["secubox-exposure"]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.exposure": ["fix_port", "tor_add", "tor_remove", "ssl_add", "ssl_remove", "set_config"]
|
||||
},
|
||||
"uci": ["secubox-exposure"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ LUCI_PKGARCH:=all
|
||||
|
||||
PKG_NAME:=luci-app-haproxy
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=6
|
||||
PKG_RELEASE:=8
|
||||
|
||||
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
||||
PKG_LICENSE:=MIT
|
||||
|
||||
@ -282,6 +282,12 @@ var callGetLogs = rpc.declare({
|
||||
expect: { logs: '' }
|
||||
});
|
||||
|
||||
var callListExposedServices = rpc.declare({
|
||||
object: 'luci.haproxy',
|
||||
method: 'list_exposed_services',
|
||||
expect: { services: [] }
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
@ -367,6 +373,9 @@ return baseclass.extend({
|
||||
validate: callValidate,
|
||||
getLogs: callGetLogs,
|
||||
|
||||
// Exposed services
|
||||
listExposedServices: callListExposedServices,
|
||||
|
||||
// Helpers
|
||||
getDashboardData: getDashboardData
|
||||
});
|
||||
|
||||
@ -23,7 +23,8 @@ return view.extend({
|
||||
var backends = (result && result.backends) || result || [];
|
||||
return Promise.all([
|
||||
Promise.resolve(backends),
|
||||
api.listServers('')
|
||||
api.listServers(''),
|
||||
api.listExposedServices()
|
||||
]);
|
||||
});
|
||||
},
|
||||
@ -33,6 +34,8 @@ return view.extend({
|
||||
var backends = data[0] || [];
|
||||
var serversResult = data[1] || {};
|
||||
var servers = (serversResult && serversResult.servers) || serversResult || [];
|
||||
var exposedResult = data[2] || {};
|
||||
self.exposedServices = (exposedResult && exposedResult.services) || exposedResult || [];
|
||||
|
||||
// Group servers by backend
|
||||
var serversByBackend = {};
|
||||
@ -405,9 +408,45 @@ return view.extend({
|
||||
|
||||
showAddServerModal: function(backend) {
|
||||
var self = this;
|
||||
var exposedServices = self.exposedServices || [];
|
||||
|
||||
// Build service selector options
|
||||
var serviceOptions = [E('option', { 'value': '' }, '-- Select a service --')];
|
||||
exposedServices.forEach(function(svc) {
|
||||
var label = svc.name + ' (' + svc.address + ':' + svc.port + ')';
|
||||
if (svc.category) label += ' [' + svc.category + ']';
|
||||
serviceOptions.push(E('option', {
|
||||
'value': JSON.stringify(svc),
|
||||
'data-name': svc.name,
|
||||
'data-address': svc.address,
|
||||
'data-port': svc.port
|
||||
}, label));
|
||||
});
|
||||
|
||||
ui.showModal('Add Server to ' + backend.name, [
|
||||
E('div', { 'style': 'max-width: 500px;' }, [
|
||||
// Quick service selector
|
||||
exposedServices.length > 0 ? E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, 'Quick Select'),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('select', {
|
||||
'id': 'modal-service-select',
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'width: 100%;',
|
||||
'change': function(ev) {
|
||||
var val = ev.target.value;
|
||||
if (val) {
|
||||
var svc = JSON.parse(val);
|
||||
document.getElementById('modal-server-name').value = svc.name;
|
||||
document.getElementById('modal-server-address').value = svc.address;
|
||||
document.getElementById('modal-server-port').value = svc.port;
|
||||
}
|
||||
}
|
||||
}, serviceOptions),
|
||||
E('small', { 'style': 'color: var(--hp-text-muted); display: block; margin-top: 4px;' },
|
||||
'Select a known service to auto-fill details, or enter manually below')
|
||||
])
|
||||
]) : null,
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
|
||||
@ -40,6 +40,7 @@ return view.extend({
|
||||
|
||||
// Build content array, filtering out nulls
|
||||
var content = [
|
||||
this.renderEmergencyBanner(status),
|
||||
this.renderPageHeader(status),
|
||||
this.renderStatsGrid(status, vhosts, backends, certificates),
|
||||
this.renderHealthGrid(status),
|
||||
@ -126,6 +127,72 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
renderEmergencyBanner: function(status) {
|
||||
var self = this;
|
||||
var haproxyRunning = status.haproxy_running;
|
||||
var containerRunning = status.container_running;
|
||||
|
||||
var statusColor = haproxyRunning ? '#22c55e' : (containerRunning ? '#f97316' : '#ef4444');
|
||||
var statusText = haproxyRunning ? 'HEALTHY' : (containerRunning ? 'DEGRADED' : 'DOWN');
|
||||
var statusIcon = haproxyRunning ? '\u2705' : (containerRunning ? '\u26A0\uFE0F' : '\u274C');
|
||||
|
||||
return E('div', {
|
||||
'class': 'hp-emergency-banner',
|
||||
'style': 'background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border: 1px solid ' + statusColor + '; border-radius: 12px; padding: 20px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; gap: 24px;'
|
||||
}, [
|
||||
// Status indicator
|
||||
E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [
|
||||
E('div', {
|
||||
'style': 'width: 64px; height: 64px; border-radius: 50%; background: ' + statusColor + '22; display: flex; align-items: center; justify-content: center; font-size: 32px; border: 3px solid ' + statusColor + ';'
|
||||
}, statusIcon),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 4px;' }, 'Service Status'),
|
||||
E('div', { 'style': 'font-size: 24px; font-weight: 700; color: ' + statusColor + ';' }, statusText),
|
||||
E('div', { 'style': 'font-size: 13px; color: #888; margin-top: 4px;' },
|
||||
'Container: ' + (containerRunning ? 'Running' : 'Stopped') +
|
||||
' \u2022 HAProxy: ' + (haproxyRunning ? 'Active' : 'Inactive'))
|
||||
])
|
||||
]),
|
||||
|
||||
// Quick health checks
|
||||
E('div', { 'style': 'display: flex; gap: 16px;' }, [
|
||||
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
|
||||
E('div', { 'style': 'font-size: 20px;' }, containerRunning ? '\u2705' : '\u274C'),
|
||||
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'Container')
|
||||
]),
|
||||
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
|
||||
E('div', { 'style': 'font-size: 20px;' }, haproxyRunning ? '\u2705' : '\u274C'),
|
||||
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'HAProxy')
|
||||
]),
|
||||
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
|
||||
E('div', { 'style': 'font-size: 20px;' }, status.config_valid !== false ? '\u2705' : '\u26A0\uFE0F'),
|
||||
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'Config')
|
||||
])
|
||||
]),
|
||||
|
||||
// Emergency actions
|
||||
E('div', { 'style': 'display: flex; gap: 12px;' }, [
|
||||
E('button', {
|
||||
'class': 'hp-btn',
|
||||
'style': 'background: #3b82f6; color: white; padding: 12px 20px; font-size: 14px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px;',
|
||||
'click': function() { self.handleRestart(); },
|
||||
'disabled': !containerRunning ? true : null
|
||||
}, ['\u{1F504}', ' Restart']),
|
||||
E('button', {
|
||||
'class': 'hp-btn',
|
||||
'style': 'background: ' + (haproxyRunning ? '#ef4444' : '#22c55e') + '; color: white; padding: 12px 20px; font-size: 14px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px;',
|
||||
'click': function() {
|
||||
if (haproxyRunning) {
|
||||
self.handleStop();
|
||||
} else {
|
||||
self.handleStart();
|
||||
}
|
||||
}
|
||||
}, haproxyRunning ? ['\u23F9\uFE0F', ' Stop'] : ['\u25B6\uFE0F', ' Start'])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatsGrid: function(status, vhosts, backends, certificates) {
|
||||
var activeVhosts = vhosts.filter(function(v) { return v.enabled; }).length;
|
||||
var activeBackends = backends.filter(function(b) { return b.enabled; }).length;
|
||||
@ -539,6 +606,19 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
handleRestart: function() {
|
||||
var self = this;
|
||||
self.showToast('Restarting HAProxy...', 'warning');
|
||||
return api.restart().then(function(res) {
|
||||
if (res.success) {
|
||||
self.showToast('HAProxy service restarted', 'success');
|
||||
return self.refreshDashboard();
|
||||
} else {
|
||||
self.showToast('Failed to restart: ' + (res.error || 'Unknown error'), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleReload: function() {
|
||||
var self = this;
|
||||
return api.reload().then(function(res) {
|
||||
|
||||
@ -1285,6 +1285,74 @@ method_get_logs() {
|
||||
json_dump
|
||||
}
|
||||
|
||||
# List exposed services (from secubox-exposure config)
|
||||
method_list_exposed_services() {
|
||||
json_init
|
||||
json_add_array "services"
|
||||
|
||||
# Load known services from exposure config
|
||||
if uci -q show secubox-exposure >/dev/null 2>&1; then
|
||||
config_load "secubox-exposure"
|
||||
config_foreach _add_exposed_service known
|
||||
fi
|
||||
|
||||
# Also scan listening ports for dynamic discovery
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
netstat -tlnp 2>/dev/null | grep LISTEN | while read line; do
|
||||
local addr_port=$(echo "$line" | awk '{print $4}')
|
||||
local port=$(echo "$addr_port" | awk -F: '{print $NF}')
|
||||
local proc=$(echo "$line" | awk '{print $7}' | cut -d'/' -f2)
|
||||
|
||||
# Skip if already added from known services or common system ports
|
||||
case "$port" in
|
||||
22|53|80|443|8404) continue ;;
|
||||
esac
|
||||
|
||||
# Only add if process name is useful
|
||||
if [ -n "$proc" ] && [ "$proc" != "-" ] && [ "$proc" != "unknown" ]; then
|
||||
json_add_object
|
||||
json_add_string "id" "dynamic_${proc}_${port}"
|
||||
json_add_string "name" "$proc"
|
||||
json_add_int "port" "$port"
|
||||
json_add_string "address" "127.0.0.1"
|
||||
json_add_string "category" "detected"
|
||||
json_add_boolean "dynamic" 1
|
||||
json_close_object
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
_add_exposed_service() {
|
||||
local section="$1"
|
||||
local default_port config_path category actual_port
|
||||
|
||||
config_get default_port "$section" default_port ""
|
||||
config_get config_path "$section" config_path ""
|
||||
config_get category "$section" category "app"
|
||||
|
||||
[ -z "$default_port" ] && return
|
||||
|
||||
# Try to get actual port from UCI config if available
|
||||
actual_port="$default_port"
|
||||
if [ -n "$config_path" ]; then
|
||||
local configured_port=$(uci -q get "$config_path" 2>/dev/null)
|
||||
[ -n "$configured_port" ] && actual_port="$configured_port"
|
||||
fi
|
||||
|
||||
json_add_object
|
||||
json_add_string "id" "$section"
|
||||
json_add_string "name" "$section"
|
||||
json_add_int "port" "$actual_port"
|
||||
json_add_string "address" "127.0.0.1"
|
||||
json_add_string "category" "$category"
|
||||
json_add_boolean "dynamic" 0
|
||||
json_close_object
|
||||
}
|
||||
|
||||
# Main RPC interface
|
||||
case "$1" in
|
||||
list)
|
||||
@ -1326,7 +1394,8 @@ case "$1" in
|
||||
"reload": {},
|
||||
"generate": {},
|
||||
"validate": {},
|
||||
"get_logs": { "lines": "integer" }
|
||||
"get_logs": { "lines": "integer" },
|
||||
"list_exposed_services": {}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
@ -1369,6 +1438,7 @@ EOF
|
||||
generate) method_generate ;;
|
||||
validate) method_validate ;;
|
||||
get_logs) method_get_logs ;;
|
||||
list_exposed_services) method_list_exposed_services ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"list_acls",
|
||||
"list_redirects",
|
||||
"get_settings",
|
||||
"get_logs"
|
||||
"get_logs",
|
||||
"list_exposed_services"
|
||||
]
|
||||
},
|
||||
"uci": ["haproxy"]
|
||||
|
||||
@ -126,7 +126,7 @@ return view.extend({
|
||||
]),
|
||||
E('div', { 'class': 'mm2-card' }, [
|
||||
E('div', { 'class': 'mm2-stat' }, [
|
||||
E('div', { 'class': 'mm2-stat-value' }, ':' + (config.port || 8082)),
|
||||
E('div', { 'class': 'mm2-stat-value' }, ':' + (config.port || 8085)),
|
||||
E('div', { 'class': 'mm2-stat-label' }, _('Web Port'))
|
||||
])
|
||||
]),
|
||||
|
||||
@ -67,7 +67,7 @@ return view.extend({
|
||||
|
||||
o = s.option(form.Value, 'port', _('Web Port'));
|
||||
o.datatype = 'port';
|
||||
o.default = '8082';
|
||||
o.default = '8085';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'address', _('Listen Address'));
|
||||
|
||||
@ -26,7 +26,7 @@ get_status() {
|
||||
fi
|
||||
|
||||
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8082")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8085")
|
||||
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
|
||||
|
||||
[ "$running" = "1" ] && web_url="http://${router_ip}:${port}"
|
||||
@ -54,7 +54,7 @@ EOF
|
||||
# Get main configuration
|
||||
get_config() {
|
||||
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8082")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8085")
|
||||
local address=$(uci -q get magicmirror2.main.address || echo "0.0.0.0")
|
||||
local data_path=$(uci -q get magicmirror2.main.data_path || echo "/srv/magicmirror2")
|
||||
local memory_limit=$(uci -q get magicmirror2.main.memory_limit || echo "512M")
|
||||
@ -327,7 +327,7 @@ set_config() {
|
||||
# Get web URL for iframe
|
||||
get_web_url() {
|
||||
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8082")
|
||||
local port=$(uci -q get magicmirror2.main.port || echo "8085")
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
|
||||
@ -10,8 +10,8 @@ LUCI_TITLE:=SecuBox Portal - Unified WebUI
|
||||
LUCI_DESCRIPTION:=Unified entry point for all SecuBox applications with tabbed navigation
|
||||
LUCI_DEPENDS:=+luci-base +luci-theme-secubox
|
||||
LUCI_PKGARCH:=all
|
||||
PKG_VERSION:=0.6.0
|
||||
PKG_RELEASE:=9
|
||||
PKG_VERSION:=0.7.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
PKG_MAINTAINER:=SecuBox Team <secubox@example.com>
|
||||
|
||||
|
||||
@ -415,7 +415,7 @@ return baseclass.extend({
|
||||
E('div', { 'class': 'sb-header-brand' }, [
|
||||
E('div', { 'class': 'sb-header-logo' }, 'S'),
|
||||
E('span', { 'class': 'sb-header-title' }, 'SecuBox'),
|
||||
E('span', { 'class': 'sb-header-version' }, 'v0.14.0')
|
||||
E('span', { 'class': 'sb-header-version' }, 'v0.15.48')
|
||||
]),
|
||||
// Navigation
|
||||
E('nav', { 'class': 'sb-header-nav' },
|
||||
|
||||
@ -61,6 +61,18 @@ return baseclass.extend({
|
||||
service: 'mitmproxy',
|
||||
version: '8.1.1'
|
||||
},
|
||||
'threat-monitor': {
|
||||
id: 'threat-monitor',
|
||||
name: 'Threat Monitor',
|
||||
desc: 'Real-time threat detection combining netifyd DPI with CrowdSec intelligence',
|
||||
icon: '\ud83d\udc41\ufe0f',
|
||||
iconBg: 'rgba(239, 68, 68, 0.15)',
|
||||
iconColor: '#ef4444',
|
||||
section: 'security',
|
||||
path: 'admin/secubox/security/threats/dashboard',
|
||||
service: null,
|
||||
version: '1.0.0'
|
||||
},
|
||||
|
||||
// Network Apps
|
||||
'bandwidth-manager': {
|
||||
@ -111,6 +123,18 @@ return baseclass.extend({
|
||||
service: null,
|
||||
version: '0.2.0'
|
||||
},
|
||||
'service-exposure': {
|
||||
id: 'service-exposure',
|
||||
name: 'Service Exposure',
|
||||
desc: 'Manage port conflicts, Tor hidden services, and HAProxy SSL backends',
|
||||
icon: '\ud83d\udd0c',
|
||||
iconBg: 'rgba(155, 89, 182, 0.15)',
|
||||
iconColor: '#9b59b6',
|
||||
section: 'network',
|
||||
path: 'admin/secubox/network/exposure',
|
||||
service: null,
|
||||
version: '1.0.0'
|
||||
},
|
||||
|
||||
// Monitoring Apps
|
||||
'media-flow': {
|
||||
@ -416,6 +440,13 @@ return baseclass.extend({
|
||||
icon: '\ud83d\udce6',
|
||||
path: 'admin/secubox/services',
|
||||
order: 8
|
||||
},
|
||||
'active-ports': {
|
||||
id: 'active-ports',
|
||||
name: 'Active Ports',
|
||||
icon: '\ud83d\udd0c',
|
||||
path: 'admin/secubox/services',
|
||||
order: 9
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -23,6 +23,11 @@ var callCrowdSecStats = rpc.declare({
|
||||
method: 'nftables_stats'
|
||||
});
|
||||
|
||||
var callSecurityStats = rpc.declare({
|
||||
object: 'luci.secubox-security-threats',
|
||||
method: 'get_security_stats'
|
||||
});
|
||||
|
||||
var callGetServices = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'get_services',
|
||||
@ -42,13 +47,16 @@ return view.extend({
|
||||
this.loadAppStatuses(),
|
||||
callCrowdSecStats().catch(function() { return null; }),
|
||||
portal.checkInstalledApps(),
|
||||
callGetServices().catch(function() { return []; })
|
||||
callGetServices().catch(function() { return []; }),
|
||||
callSecurityStats().catch(function() { return null; })
|
||||
]).then(function(results) {
|
||||
// Store installed apps info from the last promise
|
||||
self.installedApps = results[4] || {};
|
||||
// RPC expect unwraps the services array directly
|
||||
var svcResult = results[5] || [];
|
||||
self.detectedServices = Array.isArray(svcResult) ? svcResult : (svcResult.services || []);
|
||||
// Security stats
|
||||
self.securityStats = results[6] || {};
|
||||
return results;
|
||||
});
|
||||
},
|
||||
@ -104,6 +112,7 @@ return view.extend({
|
||||
var boardInfo = data[0] || {};
|
||||
var sysInfo = data[1] || {};
|
||||
var crowdSecStats = data[3] || {};
|
||||
var securityStats = this.securityStats || {};
|
||||
var self = this;
|
||||
|
||||
// Set portal app context and hide LuCI navigation
|
||||
@ -142,12 +151,13 @@ return view.extend({
|
||||
this.renderHeader(),
|
||||
// Content
|
||||
E('div', { 'class': 'sb-portal-content' }, [
|
||||
this.renderDashboardSection(boardInfo, sysInfo, crowdSecStats),
|
||||
this.renderDashboardSection(boardInfo, sysInfo, crowdSecStats, securityStats),
|
||||
this.renderSecuritySection(),
|
||||
this.renderNetworkSection(),
|
||||
this.renderMonitoringSection(),
|
||||
this.renderSystemSection(),
|
||||
this.renderServicesSection()
|
||||
this.renderServicesAppsSection(),
|
||||
this.renderActivePortsSection()
|
||||
])
|
||||
]);
|
||||
|
||||
@ -162,14 +172,14 @@ return view.extend({
|
||||
var sections = portal.getSections();
|
||||
// Sections that link to other pages vs tabs within portal
|
||||
var linkSections = ['portal', 'hub', 'admin'];
|
||||
var tabSections = ['security', 'network', 'monitoring', 'system', 'services'];
|
||||
var tabSections = ['security', 'network', 'monitoring', 'system', 'services', 'active-ports'];
|
||||
|
||||
return E('div', { 'class': 'sb-portal-header' }, [
|
||||
// Brand
|
||||
E('div', { 'class': 'sb-portal-brand' }, [
|
||||
E('div', { 'class': 'sb-portal-logo' }, 'S'),
|
||||
E('span', { 'class': 'sb-portal-title' }, 'SecuBox'),
|
||||
E('span', { 'class': 'sb-portal-version' }, 'v0.14.0')
|
||||
E('span', { 'class': 'sb-portal-version' }, 'v0.15.51')
|
||||
]),
|
||||
// Navigation
|
||||
E('nav', { 'class': 'sb-portal-nav' },
|
||||
@ -228,7 +238,7 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
renderDashboardSection: function(boardInfo, sysInfo, crowdSecStats) {
|
||||
renderDashboardSection: function(boardInfo, sysInfo, crowdSecStats, securityStats) {
|
||||
var self = this;
|
||||
var securityApps = portal.getAppsBySection('security');
|
||||
var networkApps = portal.getAppsBySection('network');
|
||||
@ -245,6 +255,12 @@ return view.extend({
|
||||
var crowdSecHealth = crowdSecStats.firewall_health || {};
|
||||
var crowdSecActive = crowdSecHealth.bouncer_running && crowdSecHealth.decisions_synced;
|
||||
|
||||
// Security stats
|
||||
var wanDropped = securityStats.wan_dropped || 0;
|
||||
var fwRejects = securityStats.firewall_rejects || 0;
|
||||
var csBans = securityStats.crowdsec_bans || 0;
|
||||
var csAlerts = securityStats.crowdsec_alerts_24h || 0;
|
||||
|
||||
return E('div', { 'class': 'sb-portal-section active', 'data-section': 'dashboard' }, [
|
||||
E('div', { 'class': 'sb-section-header' }, [
|
||||
E('h2', { 'class': 'sb-section-title' }, 'SecuBox Dashboard'),
|
||||
@ -275,32 +291,33 @@ return view.extend({
|
||||
E('div', { 'class': 'sb-quick-stat-label' }, 'Services Running')
|
||||
]),
|
||||
|
||||
// CrowdSec Blocked IPs
|
||||
// Firewall Blocked
|
||||
E('div', { 'class': 'sb-quick-stat' }, [
|
||||
E('div', { 'class': 'sb-quick-stat-header' }, [
|
||||
E('div', { 'class': 'sb-quick-stat-icon security' }, '\ud83d\udeab'),
|
||||
E('span', { 'class': 'sb-quick-stat-status ' + (crowdSecActive ? 'running' : 'warning') },
|
||||
crowdSecActive ? 'Active' : 'Inactive')
|
||||
crowdSecActive ? 'Protected' : 'Monitoring')
|
||||
]),
|
||||
E('div', { 'class': 'sb-quick-stat-value' }, totalBlocked.toLocaleString()),
|
||||
E('div', { 'class': 'sb-quick-stat-label' }, 'IPs Blocked')
|
||||
E('div', { 'class': 'sb-quick-stat-value' }, (wanDropped + fwRejects).toLocaleString()),
|
||||
E('div', { 'class': 'sb-quick-stat-label' }, 'Packets Blocked')
|
||||
]),
|
||||
|
||||
// Network Apps
|
||||
// Threat Alerts
|
||||
E('div', { 'class': 'sb-quick-stat' }, [
|
||||
E('div', { 'class': 'sb-quick-stat-header' }, [
|
||||
E('div', { 'class': 'sb-quick-stat-icon network' }, '\ud83c\udf10'),
|
||||
E('span', { 'class': 'sb-quick-stat-status running' }, 'Configured')
|
||||
E('div', { 'class': 'sb-quick-stat-icon security' }, '\ud83d\udc41\ufe0f'),
|
||||
E('span', { 'class': 'sb-quick-stat-status ' + (csAlerts > 0 ? 'warning' : 'running') },
|
||||
csAlerts > 0 ? 'Alerts' : 'Clear')
|
||||
]),
|
||||
E('div', { 'class': 'sb-quick-stat-value' }, networkApps.length),
|
||||
E('div', { 'class': 'sb-quick-stat-label' }, 'Network Tools')
|
||||
E('div', { 'class': 'sb-quick-stat-value' }, csBans + '/' + csAlerts),
|
||||
E('div', { 'class': 'sb-quick-stat-label' }, 'Bans / Alerts 24h')
|
||||
])
|
||||
]),
|
||||
|
||||
// Featured Apps
|
||||
E('h3', { 'style': 'margin: 1.5rem 0 1rem; color: var(--cyber-text-primary);' }, 'Quick Access'),
|
||||
E('div', { 'class': 'sb-app-grid' },
|
||||
this.renderFeaturedApps(['crowdsec', 'bandwidth-manager', 'media-flow', 'ndpid'])
|
||||
this.renderFeaturedApps(['crowdsec', 'threat-monitor', 'bandwidth-manager', 'media-flow'])
|
||||
),
|
||||
|
||||
// Recent Events placeholder
|
||||
@ -342,6 +359,17 @@ return view.extend({
|
||||
(crowdSecStats.ipv4_cscli_count || 0) + ' local) | IPv6: ' + blockedIPv6.toLocaleString()),
|
||||
E('span', { 'class': 'sb-events-meta' }, 'CrowdSec Firewall Protection')
|
||||
])
|
||||
]) : null,
|
||||
wanDropped > 0 ? E('div', { 'class': 'sb-events-item' }, [
|
||||
E('div', { 'class': 'sb-events-icon warning' }, '\ud83d\udc41\ufe0f'),
|
||||
E('div', { 'class': 'sb-events-content' }, [
|
||||
E('p', { 'class': 'sb-events-message' },
|
||||
'WAN Dropped: ' + wanDropped.toLocaleString() + ' | Firewall Rejects: ' + fwRejects),
|
||||
E('span', { 'class': 'sb-events-meta' }, [
|
||||
'Threat Monitor - ',
|
||||
E('a', { 'href': L.url('admin/secubox/security/threats/dashboard') }, 'View Details')
|
||||
])
|
||||
])
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
]);
|
||||
@ -416,7 +444,13 @@ return view.extend({
|
||||
'System administration and configuration tools', apps);
|
||||
},
|
||||
|
||||
renderServicesSection: function() {
|
||||
renderServicesAppsSection: function() {
|
||||
var apps = portal.getInstalledAppsBySection('services', this.installedApps);
|
||||
return this.renderAppSection('services', 'Services',
|
||||
'Application services running on your network', apps);
|
||||
},
|
||||
|
||||
renderActivePortsSection: function() {
|
||||
var self = this;
|
||||
var services = this.detectedServices || [];
|
||||
|
||||
@ -458,7 +492,8 @@ return view.extend({
|
||||
categoryOrder.forEach(function(cat) {
|
||||
if (categories[cat] && categories[cat].length > 0) {
|
||||
categories[cat].forEach(function(svc) {
|
||||
var url = window.location.protocol + '//' + window.location.hostname + svc.url;
|
||||
// Always use http:// for local services (they don't have SSL certs)
|
||||
var url = 'http://' + window.location.hostname + svc.url;
|
||||
var emoji = iconMap[svc.icon] || '⚡';
|
||||
serviceCards.push(E('a', {
|
||||
'class': 'sb-app-card sb-service-card',
|
||||
@ -486,9 +521,9 @@ return view.extend({
|
||||
});
|
||||
|
||||
if (serviceCards.length === 0) {
|
||||
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
|
||||
return E('div', { 'class': 'sb-portal-section', 'data-section': 'active-ports' }, [
|
||||
E('div', { 'class': 'sb-section-header' }, [
|
||||
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
|
||||
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Ports'),
|
||||
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
|
||||
]),
|
||||
E('div', { 'class': 'sb-section-empty' }, [
|
||||
@ -499,9 +534,9 @@ return view.extend({
|
||||
]);
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
|
||||
return E('div', { 'class': 'sb-portal-section', 'data-section': 'active-ports' }, [
|
||||
E('div', { 'class': 'sb-section-header' }, [
|
||||
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
|
||||
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Ports'),
|
||||
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
|
||||
]),
|
||||
E('div', { 'class': 'sb-app-grid' }, serviceCards)
|
||||
|
||||
@ -22,6 +22,10 @@ define Package/luci-app-secubox-security-threats/conffiles
|
||||
endef
|
||||
|
||||
define Package/luci-app-secubox-security-threats/install
|
||||
# CLI tool
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
$(INSTALL_BIN) ./root/usr/bin/secubox-stats $(1)/usr/bin/
|
||||
|
||||
# RPCD backend (MUST be 755 for ubus calls)
|
||||
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
||||
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.secubox-security-threats $(1)/usr/libexec/rpcd/
|
||||
|
||||
@ -64,6 +64,12 @@ var callRemoveWhitelist = rpc.declare({
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callGetSecurityStats = rpc.declare({
|
||||
object: 'luci.secubox-security-threats',
|
||||
method: 'get_security_stats',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// Utility Functions
|
||||
// ==============================================================================
|
||||
@ -212,13 +218,15 @@ function getDashboardData() {
|
||||
callStatus(),
|
||||
callGetActiveThreats(),
|
||||
callGetStatsByType(),
|
||||
callGetBlockedIPs()
|
||||
callGetBlockedIPs(),
|
||||
callGetSecurityStats()
|
||||
]).then(function(results) {
|
||||
return {
|
||||
status: results[0] || {},
|
||||
threats: results[1].threats || [],
|
||||
stats: results[2] || {},
|
||||
blocked: results[3].blocked || []
|
||||
blocked: results[3].blocked || [],
|
||||
securityStats: results[4] || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -235,6 +243,7 @@ return baseclass.extend({
|
||||
getStatsByType: callGetStatsByType,
|
||||
getStatsByHost: callGetStatsByHost,
|
||||
getBlockedIPs: callGetBlockedIPs,
|
||||
getSecurityStats: callGetSecurityStats,
|
||||
blockThreat: callBlockThreat,
|
||||
whitelistHost: callWhitelistHost,
|
||||
removeWhitelist: callRemoveWhitelist,
|
||||
|
||||
@ -16,6 +16,7 @@ return L.view.extend({
|
||||
var status = data.status || {};
|
||||
var stats = data.stats || {};
|
||||
var blocked = data.blocked || [];
|
||||
var securityStats = data.securityStats || {};
|
||||
|
||||
// Calculate statistics
|
||||
var threatStats = {
|
||||
@ -30,6 +31,7 @@ return L.view.extend({
|
||||
|
||||
// Build view elements
|
||||
var statusBanner = this.renderStatusBanner(status);
|
||||
var fwStatsGrid = this.renderFirewallStats(securityStats);
|
||||
var statsGrid = this.renderStatsGrid(threatStats, blocked.length);
|
||||
var threatDist = this.renderThreatDistribution(stats);
|
||||
var riskGauge = this.renderRiskGauge(threatStats.avg_score);
|
||||
@ -46,7 +48,11 @@ return L.view.extend({
|
||||
E('div', { 'class': 'cbi-map-descr' }, _('Real-time threat detection integrating netifyd DPI and CrowdSec intelligence')),
|
||||
statusBanner,
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Overview')),
|
||||
E('h3', {}, _('Firewall & Network Protection')),
|
||||
fwStatsGrid
|
||||
]),
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Threat Overview')),
|
||||
statsGrid
|
||||
]),
|
||||
E('div', { 'class': 'cbi-section', 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;' }, [
|
||||
@ -60,6 +66,61 @@ return L.view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
renderFirewallStats: function(stats) {
|
||||
var formatNumber = function(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return n.toString();
|
||||
};
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1rem;'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.wan_dropped || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('WAN Dropped')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Packets blocked at interface'))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #c62828 0%, #e53935 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.firewall_rejects || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('FW Rejects')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Firewall rule blocks'))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #6a1b9a 0%, #8e24aa 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.crowdsec_bans || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('CrowdSec Bans')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Active IP bans'))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #ef6c00 0%, #ff9800 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.crowdsec_alerts_24h || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('Alerts 24h')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('CrowdSec detections'))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #455a64 0%, #607d8b 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.invalid_connections || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('Invalid Conns')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Conntrack anomalies'))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'background: linear-gradient(135deg, #00695c 0%, #00897b 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.haproxy_connections || 0)),
|
||||
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('HAProxy Conns')),
|
||||
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Reverse proxy sessions'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatusBanner: function(status) {
|
||||
var services = [];
|
||||
var hasIssue = false;
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# SecuBox Security Stats - Quick overview
|
||||
# Copyright (C) 2026 CyberMind.fr
|
||||
|
||||
ubus call luci.secubox-security-threats get_security_stats 2>/dev/null | jsonfilter -e '@' 2>/dev/null || echo '{"error": "RPCD not available"}'
|
||||
@ -262,6 +262,72 @@ check_block_rules() {
|
||||
config_foreach check_rule_match block_rule "$category" "$risks" "$score" "$ip"
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# SECURITY STATS (Quick Overview)
|
||||
# ==============================================================================
|
||||
|
||||
# Get overall security statistics from all sources
|
||||
get_security_stats() {
|
||||
local wan_drops=0
|
||||
local fw_rejects=0
|
||||
local cs_bans=0
|
||||
local cs_alerts_24h=0
|
||||
local haproxy_conns=0
|
||||
local invalid_conns=0
|
||||
|
||||
# WAN dropped packets (from kernel stats)
|
||||
if [ -f /sys/class/net/br-wan/statistics/rx_dropped ]; then
|
||||
wan_drops=$(cat /sys/class/net/br-wan/statistics/rx_dropped 2>/dev/null)
|
||||
elif [ -f /sys/class/net/eth1/statistics/rx_dropped ]; then
|
||||
wan_drops=$(cat /sys/class/net/eth1/statistics/rx_dropped 2>/dev/null)
|
||||
fi
|
||||
wan_drops=${wan_drops:-0}
|
||||
|
||||
# Firewall rejects from logs (last 24h)
|
||||
fw_rejects=$(logread 2>/dev/null | grep -c "reject\|drop" || echo 0)
|
||||
fw_rejects=$(echo "$fw_rejects" | tr -d '\n')
|
||||
fw_rejects=${fw_rejects:-0}
|
||||
|
||||
# CrowdSec active bans
|
||||
if [ -x "$CSCLI" ]; then
|
||||
cs_bans=$($CSCLI decisions list -o json 2>/dev/null | grep -c '"id":' || echo 0)
|
||||
cs_bans=$(echo "$cs_bans" | tr -d '\n')
|
||||
cs_bans=${cs_bans:-0}
|
||||
|
||||
# CrowdSec alerts in last 24h
|
||||
cs_alerts_24h=$($CSCLI alerts list -o json --since 24h 2>/dev/null | grep -c '"id":' || echo 0)
|
||||
cs_alerts_24h=$(echo "$cs_alerts_24h" | tr -d '\n')
|
||||
cs_alerts_24h=${cs_alerts_24h:-0}
|
||||
fi
|
||||
|
||||
# Invalid connections (conntrack)
|
||||
if [ -f /proc/net/nf_conntrack ]; then
|
||||
invalid_conns=$(grep -c "INVALID\|UNREPLIED" /proc/net/nf_conntrack 2>/dev/null || echo 0)
|
||||
fi
|
||||
invalid_conns=$(echo "$invalid_conns" | tr -d '\n')
|
||||
invalid_conns=${invalid_conns:-0}
|
||||
|
||||
# HAProxy connections (if running in LXC)
|
||||
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
haproxy_conns=$(lxc-attach -n haproxy -- sh -c 'echo "show stat" | socat stdio /var/run/haproxy/admin.sock 2>/dev/null | tail -n+2 | awk -F, "{sum+=\$8} END {print sum}"' 2>/dev/null || echo 0)
|
||||
fi
|
||||
haproxy_conns=$(echo "$haproxy_conns" | tr -d '\n')
|
||||
haproxy_conns=${haproxy_conns:-0}
|
||||
|
||||
# Output JSON
|
||||
cat << EOF
|
||||
{
|
||||
"wan_dropped": $wan_drops,
|
||||
"firewall_rejects": $fw_rejects,
|
||||
"crowdsec_bans": $cs_bans,
|
||||
"crowdsec_alerts_24h": $cs_alerts_24h,
|
||||
"invalid_connections": $invalid_conns,
|
||||
"haproxy_connections": $haproxy_conns,
|
||||
"timestamp": "$(date -Iseconds)"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# STATISTICS
|
||||
# ==============================================================================
|
||||
@ -304,6 +370,8 @@ case "$1" in
|
||||
list)
|
||||
# List available methods
|
||||
json_init
|
||||
json_add_object "get_security_stats"
|
||||
json_close_object
|
||||
json_add_object "status"
|
||||
json_close_object
|
||||
json_add_object "get_active_threats"
|
||||
@ -334,6 +402,10 @@ case "$1" in
|
||||
|
||||
call)
|
||||
case "$2" in
|
||||
get_security_stats)
|
||||
get_security_stats
|
||||
;;
|
||||
|
||||
status)
|
||||
json_init
|
||||
json_add_boolean "enabled" 1
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"get_threat_history",
|
||||
"get_stats_by_type",
|
||||
"get_stats_by_host",
|
||||
"get_blocked_ips"
|
||||
"get_blocked_ips",
|
||||
"get_security_stats"
|
||||
],
|
||||
"luci.crowdsec-dashboard": [
|
||||
"decisions",
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require fs';
|
||||
'require secubox/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
@ -393,6 +394,14 @@ return view.extend({
|
||||
{ id: 'export_config', label: _('Export Configuration'), icon: '📦', variant: 'green' }
|
||||
];
|
||||
|
||||
// Critical services quick restart
|
||||
var criticalServices = [
|
||||
{ id: 'haproxy', label: 'HAProxy', icon: '⚖️' },
|
||||
{ id: 'crowdsec', label: 'CrowdSec', icon: '🛡️' },
|
||||
{ id: 'tor', label: 'Tor Shield', icon: '🧅' },
|
||||
{ id: 'gitea', label: 'Gitea', icon: '🦊' }
|
||||
];
|
||||
|
||||
return E('section', { 'class': 'sb-card' }, [
|
||||
E('div', { 'class': 'sb-card-header' }, [
|
||||
E('h2', {}, _('Quick Actions')),
|
||||
@ -410,10 +419,87 @@ return view.extend({
|
||||
E('span', { 'class': 'sb-action-icon' }, action.icon),
|
||||
E('span', { 'class': 'sb-action-label' }, action.label)
|
||||
]);
|
||||
})),
|
||||
|
||||
// Critical Services Quick Restart Section
|
||||
E('div', { 'class': 'sb-card-header', 'style': 'margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.1);' }, [
|
||||
E('h3', { 'style': 'font-size: 14px; margin: 0;' }, _('Critical Services Quick Restart')),
|
||||
E('p', { 'class': 'sb-card-subtitle', 'style': 'font-size: 12px;' }, _('One-click restart for essential services'))
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; gap: 8px; flex-wrap: wrap; padding: 0 16px 16px;' },
|
||||
criticalServices.map(function(svc) {
|
||||
return E('button', {
|
||||
'class': 'sb-service-restart-btn',
|
||||
'type': 'button',
|
||||
'style': 'display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; color: #3b82f6; cursor: pointer; font-size: 13px; transition: all 0.2s;',
|
||||
'click': function(ev) {
|
||||
self.restartService(svc.id, ev.target);
|
||||
},
|
||||
'onmouseover': function(ev) {
|
||||
ev.target.style.background = 'rgba(59, 130, 246, 0.25)';
|
||||
ev.target.style.borderColor = '#3b82f6';
|
||||
},
|
||||
'onmouseout': function(ev) {
|
||||
ev.target.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||
ev.target.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||
}
|
||||
}, [
|
||||
E('span', {}, svc.icon),
|
||||
E('span', {}, svc.label),
|
||||
E('span', { 'style': 'opacity: 0.7;' }, '🔄')
|
||||
]);
|
||||
}))
|
||||
]);
|
||||
},
|
||||
|
||||
restartService: function(serviceId, btnElement) {
|
||||
var self = this;
|
||||
|
||||
// Visual feedback
|
||||
if (btnElement) {
|
||||
btnElement.style.opacity = '0.6';
|
||||
btnElement.disabled = true;
|
||||
}
|
||||
|
||||
ui.showModal(_('Restarting Service'), [
|
||||
E('p', { 'class': 'spinning' }, _('Restarting ') + serviceId + '...')
|
||||
]);
|
||||
|
||||
// Map service to init.d script
|
||||
var serviceMap = {
|
||||
'haproxy': 'haproxy',
|
||||
'crowdsec': 'crowdsec',
|
||||
'tor': 'tor',
|
||||
'gitea': 'gitea'
|
||||
};
|
||||
|
||||
var initScript = serviceMap[serviceId] || serviceId;
|
||||
|
||||
return L.resolveDefault(
|
||||
L.Request.post(L.env.cgi_base + '/cgi-exec', 'command=/etc/init.d/' + initScript + ' restart'),
|
||||
{}
|
||||
).then(function() {
|
||||
// Also try the standard approach via fs
|
||||
return fs.exec('/etc/init.d/' + initScript, ['restart']);
|
||||
}).then(function() {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, serviceId + ' ' + _('restarted successfully')), 'info');
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
// Fallback: try via API if available
|
||||
return API.quickAction('restart_' + serviceId).then(function() {
|
||||
ui.addNotification(null, E('p', {}, serviceId + ' ' + _('restarted successfully')), 'info');
|
||||
}).catch(function() {
|
||||
ui.addNotification(null, E('p', {}, _('Failed to restart ') + serviceId + ': ' + (err.message || err)), 'error');
|
||||
});
|
||||
}).finally(function() {
|
||||
if (btnElement) {
|
||||
btnElement.style.opacity = '1';
|
||||
btnElement.disabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
runQuickAction: function(actionId) {
|
||||
ui.showModal(_('Executing action...'), [
|
||||
E('p', { 'class': 'spinning' }, _('Running ') + actionId + ' ...')
|
||||
|
||||
@ -32,7 +32,10 @@
|
||||
"listSnapshots",
|
||||
"get_appstore_apps",
|
||||
"get_appstore_app",
|
||||
"get_public_ips"
|
||||
"get_public_ips",
|
||||
"get_network_health",
|
||||
"get_vital_services",
|
||||
"get_full_health_report"
|
||||
],
|
||||
"uci": [
|
||||
"get",
|
||||
|
||||
@ -302,7 +302,13 @@ return view.extend({
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
var content = btoa(e.target.result);
|
||||
// Convert ArrayBuffer to base64 (handles UTF-8 correctly)
|
||||
var bytes = new Uint8Array(e.target.result);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
var content = btoa(binary);
|
||||
|
||||
api.uploadApp(name, content).then(function(result) {
|
||||
if (result && result.success) {
|
||||
@ -317,7 +323,7 @@ return view.extend({
|
||||
ui.addNotification(null, E('p', {}, _('Upload failed: ') + err.message), 'error');
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
reader.readAsArrayBuffer(file);
|
||||
},
|
||||
|
||||
handleActivate: function(name) {
|
||||
|
||||
@ -27,6 +27,12 @@ var callDisable = rpc.declare({
|
||||
expect: { success: false }
|
||||
});
|
||||
|
||||
var callRestart = rpc.declare({
|
||||
object: 'luci.tor-shield',
|
||||
method: 'restart',
|
||||
expect: { success: false }
|
||||
});
|
||||
|
||||
var callCircuits = rpc.declare({
|
||||
object: 'luci.tor-shield',
|
||||
method: 'circuits',
|
||||
@ -161,6 +167,7 @@ return baseclass.extend({
|
||||
getStatus: callStatus,
|
||||
enable: callEnable,
|
||||
disable: callDisable,
|
||||
restart: callRestart,
|
||||
getCircuits: callCircuits,
|
||||
newIdentity: callNewIdentity,
|
||||
checkLeaks: callCheckLeaks,
|
||||
|
||||
@ -87,6 +87,27 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
// Handle restart
|
||||
handleRestart: function() {
|
||||
var self = this;
|
||||
|
||||
ui.showModal(_('Restart Tor Shield'), [
|
||||
E('p', { 'class': 'spinning' }, _('Restarting Tor Shield service...'))
|
||||
]);
|
||||
|
||||
api.restart().then(function(result) {
|
||||
ui.hideModal();
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', _('Tor Shield is restarting. Please wait for bootstrap to complete.')), 'info');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', result.error || _('Failed to restart')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
// Handle leak test
|
||||
handleLeakTest: function() {
|
||||
var self = this;
|
||||
@ -373,6 +394,54 @@ return view.extend({
|
||||
])
|
||||
]),
|
||||
|
||||
// Health Status Minicard
|
||||
E('div', { 'class': 'tor-health-card', 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px;' }, [
|
||||
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
|
||||
E('div', {
|
||||
'class': 'tor-health-indicator',
|
||||
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (isProtected ? '#10b981' : isConnecting ? '#f59e0b' : '#6b7280') + '; box-shadow: 0 0 8px ' + (isProtected ? '#10b981' : isConnecting ? '#f59e0b' : 'transparent') + ';'
|
||||
}),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Service')),
|
||||
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
|
||||
status.running ? _('Running') : _('Stopped'))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
|
||||
E('div', {
|
||||
'class': 'tor-health-indicator',
|
||||
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.bootstrap >= 100 ? '#10b981' : status.bootstrap > 0 ? '#f59e0b' : '#6b7280') + '; box-shadow: 0 0 8px ' + (status.bootstrap >= 100 ? '#10b981' : status.bootstrap > 0 ? '#f59e0b' : 'transparent') + ';'
|
||||
}),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Bootstrap')),
|
||||
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
|
||||
status.bootstrap >= 100 ? _('Complete') : status.bootstrap + '%')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
|
||||
E('div', {
|
||||
'class': 'tor-health-indicator',
|
||||
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.dns_over_tor ? '#10b981' : '#f59e0b') + '; box-shadow: 0 0 8px ' + (status.dns_over_tor ? '#10b981' : '#f59e0b') + ';'
|
||||
}),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('DNS')),
|
||||
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
|
||||
status.dns_over_tor ? _('Protected') : _('Exposed'))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
|
||||
E('div', {
|
||||
'class': 'tor-health-indicator',
|
||||
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.kill_switch ? '#10b981' : '#6b7280') + '; box-shadow: 0 0 8px ' + (status.kill_switch ? '#10b981' : 'transparent') + ';'
|
||||
}),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Kill Switch')),
|
||||
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
|
||||
status.kill_switch ? _('Active') : _('Disabled'))
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
// Actions Card
|
||||
E('div', { 'class': 'tor-card' }, [
|
||||
E('div', { 'class': 'tor-card-header' }, [
|
||||
@ -393,6 +462,11 @@ return view.extend({
|
||||
'click': L.bind(this.handleLeakTest, this),
|
||||
'disabled': !isActive
|
||||
}, ['\uD83D\uDD0D ', _('Leak Test')]),
|
||||
E('button', {
|
||||
'class': 'tor-btn tor-btn-warning',
|
||||
'click': L.bind(this.handleRestart, this),
|
||||
'disabled': !status.enabled
|
||||
}, ['\u21BB ', _('Restart')]),
|
||||
E('a', {
|
||||
'class': 'tor-btn',
|
||||
'href': L.url('admin', 'services', 'tor-shield', 'circuits')
|
||||
|
||||
@ -707,9 +707,21 @@ save_settings() {
|
||||
}
|
||||
|
||||
# Main dispatcher
|
||||
# Restart Tor Shield service
|
||||
do_restart() {
|
||||
json_init
|
||||
|
||||
/etc/init.d/tor-shield restart >/dev/null 2>&1 &
|
||||
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Tor Shield restarting"
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
list)
|
||||
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"}}'
|
||||
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"restart":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"}}'
|
||||
;;
|
||||
call)
|
||||
case "$2" in
|
||||
@ -722,6 +734,9 @@ case "$1" in
|
||||
disable)
|
||||
do_disable
|
||||
;;
|
||||
restart)
|
||||
do_restart
|
||||
;;
|
||||
circuits)
|
||||
get_circuits
|
||||
;;
|
||||
|
||||
42
package/secubox/secubox-app-exposure/Makefile
Normal file
42
package/secubox/secubox-app-exposure/Makefile
Normal file
@ -0,0 +1,42 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-app-exposure
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=SecuBox Team <contact@secubox.dev>
|
||||
PKG_LICENSE:=MIT
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/secubox-app-exposure
|
||||
SECTION:=secubox
|
||||
CATEGORY:=SecuBox
|
||||
TITLE:=SecuBox Service Exposure Manager
|
||||
DEPENDS:=+secubox-core
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/secubox-app-exposure/description
|
||||
Unified service exposure manager for SecuBox.
|
||||
- Port conflict detection and resolution
|
||||
- Dynamic Tor hidden service management
|
||||
- HAProxy SSL reverse proxy configuration
|
||||
endef
|
||||
|
||||
define Package/secubox-app-exposure/conffiles
|
||||
/etc/config/secubox-exposure
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/secubox-app-exposure/install
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/etc/config/secubox-exposure $(1)/etc/config/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/sbin
|
||||
$(INSTALL_BIN) ./files/usr/sbin/secubox-exposure $(1)/usr/sbin/
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,secubox-app-exposure))
|
||||
@ -0,0 +1,63 @@
|
||||
# SecuBox Service Exposure Manager Configuration
|
||||
|
||||
config settings 'main'
|
||||
option enabled '1'
|
||||
option tor_enabled '1'
|
||||
option ssl_enabled '1'
|
||||
option haproxy_config '/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg'
|
||||
option haproxy_certs '/srv/lxc/haproxy/rootfs/etc/haproxy/certs'
|
||||
option tor_hidden_dir '/var/lib/tor/hidden_services'
|
||||
option tor_config '/etc/tor/torrc'
|
||||
|
||||
# Port ranges for auto-assignment
|
||||
config ports 'ranges'
|
||||
option app_start '8100'
|
||||
option app_end '8199'
|
||||
option monitoring_start '8200'
|
||||
option monitoring_end '8299'
|
||||
|
||||
# Known service definitions with default ports
|
||||
config known 'gitea'
|
||||
option default_port '3000'
|
||||
option config_path 'gitea.main.http_port'
|
||||
option category 'app'
|
||||
|
||||
config known 'streamlit'
|
||||
option default_port '8501'
|
||||
option config_path 'streamlit.main.port'
|
||||
option category 'app'
|
||||
|
||||
config known 'hexojs'
|
||||
option default_port '4000'
|
||||
option config_path 'hexojs.main.port'
|
||||
option category 'app'
|
||||
|
||||
config known 'cyberfeed'
|
||||
option default_port '8082'
|
||||
option config_path 'cyberfeed.main.port'
|
||||
option category 'app'
|
||||
|
||||
config known 'crowdsec'
|
||||
option default_port '6060'
|
||||
option config_file '/etc/crowdsec/config.yaml'
|
||||
option category 'security'
|
||||
|
||||
config known 'netifyd'
|
||||
option default_port '8086'
|
||||
option config_path 'netifyd.main.port'
|
||||
option category 'monitoring'
|
||||
|
||||
config known 'domoticz'
|
||||
option default_port '8080'
|
||||
option config_type 'docker'
|
||||
option category 'app'
|
||||
|
||||
# Service exposure entries (dynamically managed)
|
||||
# Example:
|
||||
# config service 'gitea'
|
||||
# option port '3000'
|
||||
# option local '1'
|
||||
# option tor '1'
|
||||
# option tor_onion 'abc123xyz.onion'
|
||||
# option ssl '1'
|
||||
# option ssl_domain 'git.example.com'
|
||||
733
package/secubox/secubox-app-exposure/files/usr/sbin/secubox-exposure
Executable file
733
package/secubox/secubox-app-exposure/files/usr/sbin/secubox-exposure
Executable file
@ -0,0 +1,733 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# SecuBox Service Exposure Manager
|
||||
# Unified tool for port management, Tor hidden services, and HAProxy SSL
|
||||
#
|
||||
|
||||
. /lib/functions.sh
|
||||
. /usr/share/libubox/jshn.sh
|
||||
|
||||
CONFIG_NAME="secubox-exposure"
|
||||
HAPROXY_CONFIG=""
|
||||
HAPROXY_CERTS=""
|
||||
TOR_HIDDEN_DIR=""
|
||||
TOR_CONFIG=""
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_err() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
load_config() {
|
||||
config_load "$CONFIG_NAME"
|
||||
config_get HAPROXY_CONFIG main haproxy_config "/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg"
|
||||
config_get HAPROXY_CERTS main haproxy_certs "/srv/lxc/haproxy/rootfs/etc/haproxy/certs"
|
||||
config_get TOR_HIDDEN_DIR main tor_hidden_dir "/var/lib/tor/hidden_services"
|
||||
config_get TOR_CONFIG main tor_config "/etc/tor/torrc"
|
||||
config_get APP_PORT_START ranges app_start "8100"
|
||||
config_get APP_PORT_END ranges app_end "8199"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# PORT SCANNING & CONFLICT DETECTION
|
||||
# ============================================================================
|
||||
|
||||
get_listening_ports() {
|
||||
# Returns: port address process
|
||||
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
|
||||
split($4, a, ":")
|
||||
port = a[length(a)]
|
||||
if (!seen[port]++) {
|
||||
split($7, p, "/")
|
||||
proc = p[2]
|
||||
if (proc == "") proc = "unknown"
|
||||
print port, $4, proc
|
||||
}
|
||||
}' | sort -n
|
||||
}
|
||||
|
||||
cmd_scan() {
|
||||
log_info "Scanning listening services..."
|
||||
echo ""
|
||||
printf "%-6s %-20s %-15s %-10s\n" "PORT" "ADDRESS" "PROCESS" "STATUS"
|
||||
printf "%-6s %-20s %-15s %-10s\n" "------" "--------------------" "---------------" "----------"
|
||||
|
||||
get_listening_ports | while read port addr proc; do
|
||||
# Determine if external
|
||||
case "$addr" in
|
||||
*0.0.0.0*|*::*) status="${GREEN}external${NC}" ;;
|
||||
*127.0.0.1*|*::1*) status="${YELLOW}local${NC}" ;;
|
||||
*) status="${CYAN}bound${NC}" ;;
|
||||
esac
|
||||
printf "%-6s %-20s %-15s " "$port" "$addr" "$proc"
|
||||
echo -e "$status"
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
cmd_conflicts() {
|
||||
log_info "Checking for port conflicts..."
|
||||
echo ""
|
||||
|
||||
local conflicts=0
|
||||
local TMP_PORTS="/tmp/ports_$$"
|
||||
|
||||
# Get all configured ports from UCI
|
||||
> "$TMP_PORTS"
|
||||
|
||||
# Check known services
|
||||
check_known_service() {
|
||||
local section="$1"
|
||||
local default_port config_path
|
||||
config_get default_port "$section" default_port
|
||||
config_get config_path "$section" config_path
|
||||
|
||||
if [ -n "$config_path" ]; then
|
||||
# Extract UCI config and option
|
||||
local uci_config=$(echo "$config_path" | cut -d'.' -f1)
|
||||
local uci_option=$(echo "$config_path" | cut -d'.' -f2-)
|
||||
local actual_port=$(uci -q get "$config_path" 2>/dev/null)
|
||||
[ -z "$actual_port" ] && actual_port="$default_port"
|
||||
echo "$actual_port $section" >> "$TMP_PORTS"
|
||||
fi
|
||||
}
|
||||
config_foreach check_known_service known
|
||||
|
||||
# Find duplicates
|
||||
sort "$TMP_PORTS" | uniq -d -w5 | while read port svc; do
|
||||
log_warn "Port $port is configured for multiple services!"
|
||||
grep "^$port " "$TMP_PORTS" | while read p s; do
|
||||
echo " - $s"
|
||||
done
|
||||
conflicts=$((conflicts + 1))
|
||||
done
|
||||
|
||||
# Check against actually listening ports
|
||||
get_listening_ports | while read port addr proc; do
|
||||
if grep -q "^$port " "$TMP_PORTS"; then
|
||||
local configured_svc=$(grep "^$port " "$TMP_PORTS" | head -1 | cut -d' ' -f2)
|
||||
# Check if process matches expected
|
||||
case "$configured_svc" in
|
||||
gitea) [ "$proc" != "gitea" ] && log_warn "Port $port: expected gitea, found $proc" ;;
|
||||
streamlit) echo "$proc" | grep -qv "python\|streamlit" && log_warn "Port $port: expected streamlit, found $proc" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$TMP_PORTS"
|
||||
|
||||
if [ "$conflicts" -eq 0 ]; then
|
||||
log_ok "No port conflicts detected"
|
||||
fi
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local start="$1"
|
||||
local end="$2"
|
||||
local port="$start"
|
||||
|
||||
while [ "$port" -le "$end" ]; do
|
||||
if ! netstat -tlnp 2>/dev/null | grep -q ":$port "; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_fix_port() {
|
||||
local service="$1"
|
||||
local new_port="$2"
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
log_err "Usage: secubox-exposure fix-port <service> [new_port]"
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_config
|
||||
|
||||
# Get service config
|
||||
local config_path default_port
|
||||
config_get config_path "$service" config_path
|
||||
config_get default_port "$service" default_port
|
||||
|
||||
if [ -z "$config_path" ]; then
|
||||
log_err "Unknown service: $service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Find free port if not specified
|
||||
if [ -z "$new_port" ]; then
|
||||
new_port=$(find_free_port "$APP_PORT_START" "$APP_PORT_END")
|
||||
if [ -z "$new_port" ]; then
|
||||
log_err "No free ports available in range $APP_PORT_START-$APP_PORT_END"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if new port is free
|
||||
if netstat -tlnp 2>/dev/null | grep -q ":$new_port "; then
|
||||
log_err "Port $new_port is already in use"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Changing $service port to $new_port"
|
||||
|
||||
# Update UCI
|
||||
if uci set "$config_path=$new_port" && uci commit; then
|
||||
log_ok "UCI config updated"
|
||||
|
||||
# Restart service if it has an init script
|
||||
if [ -x "/etc/init.d/$service" ]; then
|
||||
log_info "Restarting $service..."
|
||||
/etc/init.d/"$service" restart
|
||||
fi
|
||||
|
||||
log_ok "$service now listening on port $new_port"
|
||||
else
|
||||
log_err "Failed to update UCI config"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TOR HIDDEN SERVICES
|
||||
# ============================================================================
|
||||
|
||||
cmd_tor_add() {
|
||||
local service="$1"
|
||||
local local_port="$2"
|
||||
local onion_port="${3:-80}"
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
log_err "Usage: secubox-exposure tor add <service> [local_port] [onion_port]"
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_config
|
||||
|
||||
# Get local port from config if not specified
|
||||
if [ -z "$local_port" ]; then
|
||||
config_get local_port "$service" default_port
|
||||
if [ -z "$local_port" ]; then
|
||||
log_err "Cannot determine local port for $service"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local hidden_dir="$TOR_HIDDEN_DIR/$service"
|
||||
|
||||
# Create hidden service directory
|
||||
mkdir -p "$hidden_dir"
|
||||
chmod 700 "$hidden_dir"
|
||||
chown tor:tor "$hidden_dir" 2>/dev/null || chown debian-tor:debian-tor "$hidden_dir" 2>/dev/null
|
||||
|
||||
# Check if already configured in torrc
|
||||
if grep -q "HiddenServiceDir $hidden_dir" "$TOR_CONFIG" 2>/dev/null; then
|
||||
log_warn "Hidden service for $service already exists"
|
||||
local onion=$(cat "$hidden_dir/hostname" 2>/dev/null)
|
||||
[ -n "$onion" ] && log_info "Onion address: $onion"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Add to torrc
|
||||
log_info "Adding hidden service for $service (127.0.0.1:$local_port -> :$onion_port)"
|
||||
|
||||
cat >> "$TOR_CONFIG" << EOF
|
||||
|
||||
# Hidden service for $service (added by secubox-exposure)
|
||||
HiddenServiceDir $hidden_dir
|
||||
HiddenServicePort $onion_port 127.0.0.1:$local_port
|
||||
EOF
|
||||
|
||||
# Restart Tor
|
||||
log_info "Restarting Tor..."
|
||||
/etc/init.d/tor restart 2>/dev/null || systemctl restart tor 2>/dev/null
|
||||
|
||||
# Wait for onion address
|
||||
log_info "Waiting for onion address generation..."
|
||||
local tries=0
|
||||
while [ ! -f "$hidden_dir/hostname" ] && [ "$tries" -lt 30 ]; do
|
||||
sleep 1
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
if [ -f "$hidden_dir/hostname" ]; then
|
||||
local onion=$(cat "$hidden_dir/hostname")
|
||||
log_ok "Hidden service created!"
|
||||
echo ""
|
||||
echo -e " ${CYAN}Service:${NC} $service"
|
||||
echo -e " ${CYAN}Onion:${NC} $onion"
|
||||
echo -e " ${CYAN}Port:${NC} $onion_port -> 127.0.0.1:$local_port"
|
||||
echo ""
|
||||
|
||||
# Save to exposure UCI
|
||||
uci set "${CONFIG_NAME}.${service}=service"
|
||||
uci set "${CONFIG_NAME}.${service}.port=$local_port"
|
||||
uci set "${CONFIG_NAME}.${service}.tor=1"
|
||||
uci set "${CONFIG_NAME}.${service}.tor_onion=$onion"
|
||||
uci set "${CONFIG_NAME}.${service}.tor_port=$onion_port"
|
||||
uci commit "$CONFIG_NAME"
|
||||
|
||||
# Sync to Tor Shield UCI
|
||||
local hs_name="hs_${service}"
|
||||
uci set "tor-shield.${hs_name}=hidden_service"
|
||||
uci set "tor-shield.${hs_name}.name=${service}"
|
||||
uci set "tor-shield.${hs_name}.enabled=1"
|
||||
uci set "tor-shield.${hs_name}.local_port=${local_port}"
|
||||
uci set "tor-shield.${hs_name}.onion_port=${onion_port}"
|
||||
uci set "tor-shield.${hs_name}.onion_address=${onion}"
|
||||
uci commit tor-shield
|
||||
log_ok "Synced to Tor Shield"
|
||||
else
|
||||
log_err "Failed to generate onion address"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_tor_list() {
|
||||
load_config
|
||||
|
||||
log_info "Tor Hidden Services:"
|
||||
echo ""
|
||||
printf "%-15s %-62s %-10s\n" "SERVICE" "ONION ADDRESS" "PORT"
|
||||
printf "%-15s %-62s %-10s\n" "---------------" "--------------------------------------------------------------" "----------"
|
||||
|
||||
# List from filesystem
|
||||
if [ -d "$TOR_HIDDEN_DIR" ]; then
|
||||
for dir in "$TOR_HIDDEN_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local svc=$(basename "$dir")
|
||||
local onion=""
|
||||
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
|
||||
|
||||
# Get port from torrc
|
||||
local port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
|
||||
|
||||
if [ -n "$onion" ]; then
|
||||
printf "%-15s %-62s %-10s\n" "$svc" "$onion" "${port:-80}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
cmd_tor_remove() {
|
||||
local service="$1"
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
log_err "Usage: secubox-exposure tor remove <service>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_config
|
||||
|
||||
local hidden_dir="$TOR_HIDDEN_DIR/$service"
|
||||
|
||||
if [ ! -d "$hidden_dir" ]; then
|
||||
log_err "No hidden service found for $service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Removing hidden service for $service"
|
||||
|
||||
# Remove from torrc (remove the block)
|
||||
sed -i "/# Hidden service for $service/,/HiddenServicePort/d" "$TOR_CONFIG"
|
||||
|
||||
# Remove directory
|
||||
rm -rf "$hidden_dir"
|
||||
|
||||
# Update exposure UCI
|
||||
uci delete "${CONFIG_NAME}.${service}.tor" 2>/dev/null
|
||||
uci delete "${CONFIG_NAME}.${service}.tor_onion" 2>/dev/null
|
||||
uci delete "${CONFIG_NAME}.${service}.tor_port" 2>/dev/null
|
||||
uci commit "$CONFIG_NAME"
|
||||
|
||||
# Remove from Tor Shield UCI
|
||||
local hs_name="hs_${service}"
|
||||
if uci -q get "tor-shield.${hs_name}" >/dev/null 2>&1; then
|
||||
uci delete "tor-shield.${hs_name}"
|
||||
uci commit tor-shield
|
||||
log_ok "Removed from Tor Shield"
|
||||
fi
|
||||
|
||||
# Restart Tor
|
||||
/etc/init.d/tor restart 2>/dev/null || systemctl restart tor 2>/dev/null
|
||||
|
||||
log_ok "Hidden service removed"
|
||||
}
|
||||
|
||||
cmd_tor_sync() {
|
||||
load_config
|
||||
|
||||
log_info "Syncing hidden services to Tor Shield..."
|
||||
local synced=0
|
||||
|
||||
# List from filesystem and sync to Tor Shield
|
||||
if [ -d "$TOR_HIDDEN_DIR" ]; then
|
||||
for dir in "$TOR_HIDDEN_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local svc=$(basename "$dir")
|
||||
local onion=""
|
||||
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
|
||||
|
||||
# Get port from torrc
|
||||
local port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
|
||||
local local_port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{split($3,a,":"); print a[2]}')
|
||||
|
||||
if [ -n "$onion" ]; then
|
||||
local hs_name="hs_${svc}"
|
||||
if ! uci -q get "tor-shield.${hs_name}" >/dev/null 2>&1; then
|
||||
log_info "Adding $svc to Tor Shield"
|
||||
uci set "tor-shield.${hs_name}=hidden_service"
|
||||
uci set "tor-shield.${hs_name}.name=${svc}"
|
||||
uci set "tor-shield.${hs_name}.enabled=1"
|
||||
uci set "tor-shield.${hs_name}.local_port=${local_port:-80}"
|
||||
uci set "tor-shield.${hs_name}.onion_port=${port:-80}"
|
||||
uci set "tor-shield.${hs_name}.onion_address=${onion}"
|
||||
synced=$((synced + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$synced" -gt 0 ]; then
|
||||
uci commit tor-shield
|
||||
log_ok "Synced $synced hidden service(s) to Tor Shield"
|
||||
else
|
||||
log_info "All hidden services already synced"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HAPROXY SSL BACKENDS (UCI-based integration with haproxyctl)
|
||||
# ============================================================================
|
||||
|
||||
# Sanitize name for UCI section (replace dots/hyphens with underscores)
|
||||
sanitize_uci_name() {
|
||||
echo "$1" | sed 's/[.-]/_/g'
|
||||
}
|
||||
|
||||
cmd_ssl_add() {
|
||||
local service="$1"
|
||||
local domain="$2"
|
||||
local local_port="$3"
|
||||
|
||||
if [ -z "$service" ] || [ -z "$domain" ]; then
|
||||
log_err "Usage: secubox-exposure ssl add <service> <domain> [local_port]"
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_config
|
||||
|
||||
# Get local port from config if not specified
|
||||
if [ -z "$local_port" ]; then
|
||||
config_get local_port "$service" default_port
|
||||
# Try to get from service UCI
|
||||
local config_path
|
||||
config_get config_path "$service" config_path
|
||||
if [ -n "$config_path" ]; then
|
||||
local configured_port=$(uci -q get "$config_path")
|
||||
[ -n "$configured_port" ] && local_port="$configured_port"
|
||||
fi
|
||||
if [ -z "$local_port" ]; then
|
||||
log_err "Cannot determine local port for $service. Specify it manually."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if haproxyctl exists
|
||||
if [ ! -x "/usr/sbin/haproxyctl" ]; then
|
||||
log_err "haproxyctl not found. Is secubox-app-haproxy installed?"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Sanitize names for UCI
|
||||
local backend_name="$service"
|
||||
local vhost_name=$(sanitize_uci_name "$domain")
|
||||
|
||||
# Check if backend already exists in UCI
|
||||
if uci -q get "haproxy.${backend_name}" >/dev/null 2>&1; then
|
||||
log_warn "Backend '$backend_name' already exists in HAProxy UCI config"
|
||||
else
|
||||
# Create backend in HAProxy UCI config
|
||||
log_info "Adding backend '$backend_name' (127.0.0.1:$local_port)"
|
||||
uci set "haproxy.${backend_name}=backend"
|
||||
uci set "haproxy.${backend_name}.name=${backend_name}"
|
||||
uci set "haproxy.${backend_name}.mode=http"
|
||||
uci set "haproxy.${backend_name}.balance=roundrobin"
|
||||
uci set "haproxy.${backend_name}.enabled=1"
|
||||
uci add_list "haproxy.${backend_name}.server=${service} 127.0.0.1:${local_port} check"
|
||||
fi
|
||||
|
||||
# Check if vhost already exists
|
||||
if uci -q get "haproxy.${vhost_name}" >/dev/null 2>&1; then
|
||||
log_warn "Vhost for '$domain' already exists"
|
||||
else
|
||||
# Create vhost in HAProxy UCI config
|
||||
log_info "Adding vhost '$domain' -> backend '$backend_name'"
|
||||
uci set "haproxy.${vhost_name}=vhost"
|
||||
uci set "haproxy.${vhost_name}.domain=${domain}"
|
||||
uci set "haproxy.${vhost_name}.backend=${backend_name}"
|
||||
uci set "haproxy.${vhost_name}.ssl=1"
|
||||
uci set "haproxy.${vhost_name}.ssl_redirect=1"
|
||||
uci set "haproxy.${vhost_name}.enabled=1"
|
||||
fi
|
||||
|
||||
# Commit HAProxy UCI changes
|
||||
uci commit haproxy
|
||||
|
||||
# Also save to exposure UCI for tracking
|
||||
uci set "${CONFIG_NAME}.${service}=service"
|
||||
uci set "${CONFIG_NAME}.${service}.port=$local_port"
|
||||
uci set "${CONFIG_NAME}.${service}.ssl=1"
|
||||
uci set "${CONFIG_NAME}.${service}.ssl_domain=$domain"
|
||||
uci commit "$CONFIG_NAME"
|
||||
|
||||
log_ok "HAProxy UCI config updated"
|
||||
log_info "Domain: $domain -> 127.0.0.1:$local_port"
|
||||
|
||||
# Regenerate and reload HAProxy
|
||||
log_info "Regenerating HAProxy config..."
|
||||
/usr/sbin/haproxyctl generate
|
||||
|
||||
log_info "Reloading HAProxy..."
|
||||
/usr/sbin/haproxyctl reload
|
||||
|
||||
log_ok "SSL backend configured"
|
||||
log_warn "Note: Ensure SSL certificate exists for $domain"
|
||||
}
|
||||
|
||||
cmd_ssl_list() {
|
||||
load_config
|
||||
|
||||
log_info "HAProxy SSL Backends:"
|
||||
echo ""
|
||||
printf "%-15s %-30s %-20s\n" "SERVICE" "DOMAIN" "BACKEND"
|
||||
printf "%-15s %-30s %-20s\n" "---------------" "------------------------------" "--------------------"
|
||||
|
||||
# Read from HAProxy UCI config (vhosts with their backends)
|
||||
local found=0
|
||||
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
local domain=$(uci -q get "haproxy.${vhost}.domain")
|
||||
local backend=$(uci -q get "haproxy.${vhost}.backend")
|
||||
local enabled=$(uci -q get "haproxy.${vhost}.enabled")
|
||||
|
||||
[ "$enabled" != "1" ] && continue
|
||||
[ -z "$domain" ] && continue
|
||||
|
||||
# Get server from backend
|
||||
local server=""
|
||||
if [ -n "$backend" ]; then
|
||||
server=$(uci -q get "haproxy.${backend}.server" | head -1 | awk '{print $2}')
|
||||
fi
|
||||
|
||||
printf "%-15s %-30s %-20s\n" "${backend:-N/A}" "$domain" "${server:-N/A}"
|
||||
found=1
|
||||
done
|
||||
|
||||
[ "$found" = "0" ] && echo " No SSL backends configured"
|
||||
echo ""
|
||||
}
|
||||
|
||||
cmd_ssl_remove() {
|
||||
local service="$1"
|
||||
|
||||
if [ -z "$service" ]; then
|
||||
log_err "Usage: secubox-exposure ssl remove <service>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
load_config
|
||||
|
||||
# Check if haproxyctl exists
|
||||
if [ ! -x "/usr/sbin/haproxyctl" ]; then
|
||||
log_err "haproxyctl not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local backend_name="$service"
|
||||
local removed=0
|
||||
|
||||
# Find and remove vhosts pointing to this backend
|
||||
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
local vhost_backend=$(uci -q get "haproxy.${vhost}.backend")
|
||||
if [ "$vhost_backend" = "$backend_name" ]; then
|
||||
log_info "Removing vhost '$vhost'"
|
||||
uci delete "haproxy.${vhost}"
|
||||
removed=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove backend if it exists
|
||||
if uci -q get "haproxy.${backend_name}" >/dev/null 2>&1; then
|
||||
log_info "Removing backend '$backend_name'"
|
||||
uci delete "haproxy.${backend_name}"
|
||||
removed=1
|
||||
fi
|
||||
|
||||
if [ "$removed" = "0" ]; then
|
||||
log_err "No backend or vhost found for '$service'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Commit HAProxy UCI changes
|
||||
uci commit haproxy
|
||||
|
||||
# Update exposure UCI
|
||||
uci delete "${CONFIG_NAME}.${service}.ssl" 2>/dev/null
|
||||
uci delete "${CONFIG_NAME}.${service}.ssl_domain" 2>/dev/null
|
||||
uci commit "$CONFIG_NAME"
|
||||
|
||||
# Regenerate and reload HAProxy
|
||||
log_info "Regenerating HAProxy config..."
|
||||
/usr/sbin/haproxyctl generate
|
||||
|
||||
log_info "Reloading HAProxy..."
|
||||
/usr/sbin/haproxyctl reload
|
||||
|
||||
log_ok "SSL backend removed"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# STATUS & HELP
|
||||
# ============================================================================
|
||||
|
||||
cmd_status() {
|
||||
load_config
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN} SecuBox Service Exposure Status${NC}"
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
# Count services
|
||||
local total_services=$(get_listening_ports | wc -l)
|
||||
local external_services=$(get_listening_ports | grep -E "0\.0\.0\.0|::" | wc -l)
|
||||
|
||||
echo -e "${BLUE}Services:${NC}"
|
||||
echo " Total listening: $total_services"
|
||||
echo " External (0.0.0.0): $external_services"
|
||||
echo ""
|
||||
|
||||
# Tor status
|
||||
local tor_services=0
|
||||
[ -d "$TOR_HIDDEN_DIR" ] && tor_services=$(ls -1 "$TOR_HIDDEN_DIR" 2>/dev/null | wc -l)
|
||||
echo -e "${BLUE}Tor Hidden Services:${NC} $tor_services"
|
||||
if [ "$tor_services" -gt 0 ]; then
|
||||
for dir in "$TOR_HIDDEN_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local svc=$(basename "$dir")
|
||||
local onion=$(cat "$dir/hostname" 2>/dev/null)
|
||||
[ -n "$onion" ] && echo " - $svc: ${onion:0:16}..."
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# HAProxy backends (from UCI)
|
||||
local ssl_backends=0
|
||||
echo -e "${BLUE}HAProxy SSL Backends:${NC}"
|
||||
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
|
||||
local domain=$(uci -q get "haproxy.${vhost}.domain")
|
||||
local backend=$(uci -q get "haproxy.${vhost}.backend")
|
||||
local enabled=$(uci -q get "haproxy.${vhost}.enabled")
|
||||
[ "$enabled" != "1" ] && continue
|
||||
[ -z "$domain" ] && continue
|
||||
echo " - ${backend}: ${domain}"
|
||||
ssl_backends=$((ssl_backends + 1))
|
||||
done
|
||||
[ "$ssl_backends" = "0" ] && echo " (none configured)"
|
||||
echo ""
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
cat << EOF
|
||||
SecuBox Service Exposure Manager
|
||||
|
||||
Usage: secubox-exposure <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
scan Scan all listening services
|
||||
conflicts Detect port conflicts
|
||||
fix-port <svc> [port] Change service port (auto-assigns if no port given)
|
||||
status Show exposure status summary
|
||||
|
||||
tor add <svc> [port] Create Tor hidden service
|
||||
tor list List hidden services
|
||||
tor remove <svc> Remove hidden service
|
||||
tor sync Sync hidden services to Tor Shield
|
||||
|
||||
ssl add <svc> <domain> Add HAProxy SSL backend
|
||||
ssl list List SSL backends
|
||||
ssl remove <svc> Remove SSL backend
|
||||
|
||||
EXAMPLES:
|
||||
secubox-exposure scan
|
||||
secubox-exposure conflicts
|
||||
secubox-exposure fix-port domoticz 8180
|
||||
|
||||
secubox-exposure tor add gitea
|
||||
secubox-exposure tor add streamlit 8501 80
|
||||
secubox-exposure tor list
|
||||
|
||||
secubox-exposure ssl add gitea git.example.com
|
||||
secubox-exposure ssl add streamlit app.example.com 8501
|
||||
secubox-exposure ssl list
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
case "$1" in
|
||||
scan)
|
||||
cmd_scan
|
||||
;;
|
||||
conflicts)
|
||||
load_config
|
||||
cmd_conflicts
|
||||
;;
|
||||
fix-port)
|
||||
cmd_fix_port "$2" "$3"
|
||||
;;
|
||||
status)
|
||||
cmd_status
|
||||
;;
|
||||
tor)
|
||||
case "$2" in
|
||||
add) cmd_tor_add "$3" "$4" "$5" ;;
|
||||
list) cmd_tor_list ;;
|
||||
remove) cmd_tor_remove "$3" ;;
|
||||
sync) cmd_tor_sync ;;
|
||||
*) log_err "Usage: secubox-exposure tor {add|list|remove|sync}"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
ssl)
|
||||
case "$2" in
|
||||
add) cmd_ssl_add "$3" "$4" "$5" ;;
|
||||
list) cmd_ssl_list ;;
|
||||
remove) cmd_ssl_remove "$3" ;;
|
||||
*) log_err "Usage: secubox-exposure ssl {add|list|remove}"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
help|--help|-h)
|
||||
cmd_help
|
||||
;;
|
||||
*)
|
||||
cmd_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -91,6 +91,15 @@ Commands:
|
||||
--password <pass>
|
||||
--email <email>
|
||||
|
||||
mirror-sync <repo> Sync a mirrored repository
|
||||
mirror-list List all mirrored repositories
|
||||
mirror-create Create a new mirror from URL
|
||||
--name <name>
|
||||
--url <github-url>
|
||||
--owner <user> (default: first admin user)
|
||||
|
||||
repo-list List all repositories
|
||||
|
||||
service-run Start service (used by init)
|
||||
service-stop Stop service (used by init)
|
||||
|
||||
@ -718,6 +727,235 @@ cmd_admin_create_user() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Get Gitea API token (from admin user or config)
|
||||
get_api_token() {
|
||||
local token
|
||||
token="$(uci_get main.api_token)"
|
||||
if [ -n "$token" ]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try to get token from container
|
||||
if lxc_running; then
|
||||
token=$(lxc-attach -n "$LXC_NAME" -- cat /data/api_token 2>/dev/null)
|
||||
if [ -n "$token" ]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get Gitea API URL
|
||||
get_api_url() {
|
||||
load_config
|
||||
echo "http://127.0.0.1:${http_port}/api/v1"
|
||||
}
|
||||
|
||||
# Make Gitea API call
|
||||
gitea_api() {
|
||||
local method="$1"
|
||||
local endpoint="$2"
|
||||
local data="$3"
|
||||
local token
|
||||
|
||||
token=$(get_api_token) || {
|
||||
log_error "No API token configured. Set with: uci set gitea.main.api_token=<token>"
|
||||
log_error "Generate token in Gitea: Settings → Applications → Generate Token"
|
||||
return 1
|
||||
}
|
||||
|
||||
local api_url=$(get_api_url)
|
||||
local url="${api_url}${endpoint}"
|
||||
|
||||
if [ "$method" = "GET" ]; then
|
||||
wget -q -O- --header="Authorization: token $token" "$url" 2>/dev/null
|
||||
elif [ "$method" = "POST" ]; then
|
||||
if [ -n "$data" ]; then
|
||||
wget -q -O- --header="Authorization: token $token" \
|
||||
--header="Content-Type: application/json" \
|
||||
--post-data="$data" "$url" 2>/dev/null
|
||||
else
|
||||
wget -q -O- --header="Authorization: token $token" \
|
||||
--post-data="" "$url" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_mirror_sync() {
|
||||
load_config
|
||||
local repo_name="$1"
|
||||
|
||||
if [ -z "$repo_name" ]; then
|
||||
log_error "Usage: giteactl mirror-sync <owner/repo> or <repo>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Gitea container is not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If no owner specified, try to find the repo
|
||||
if ! echo "$repo_name" | grep -q "/"; then
|
||||
# Search for repo in all users
|
||||
local found_owner
|
||||
found_owner=$(gitea_api GET "/repos/search?q=$repo_name" 2>/dev/null | \
|
||||
jsonfilter -e '@.data[0].owner.login' 2>/dev/null)
|
||||
if [ -n "$found_owner" ]; then
|
||||
repo_name="${found_owner}/${repo_name}"
|
||||
else
|
||||
log_error "Repository not found: $repo_name"
|
||||
log_error "Specify full path: owner/repo"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Syncing mirror: $repo_name"
|
||||
|
||||
# Trigger mirror sync via API
|
||||
local result
|
||||
result=$(gitea_api POST "/repos/${repo_name}/mirror-sync" 2>&1)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_info "Mirror sync triggered for $repo_name"
|
||||
log_info "Check progress in Gitea web UI"
|
||||
else
|
||||
log_error "Failed to sync mirror: $result"
|
||||
|
||||
# Try alternative: use gitea command directly in container
|
||||
log_info "Trying direct sync via container..."
|
||||
lxc-attach -n "$LXC_NAME" -- su-exec git /usr/local/bin/gitea admin repo-sync-releases \
|
||||
--config /data/custom/conf/app.ini 2>/dev/null || true
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_mirror_list() {
|
||||
load_config
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Gitea container is not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Fetching mirror repositories..."
|
||||
|
||||
local repos
|
||||
repos=$(gitea_api GET "/repos/search?mirror=true&limit=50" 2>/dev/null)
|
||||
|
||||
if [ -z "$repos" ]; then
|
||||
echo "No mirrored repositories found (or API token not set)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Mirrored Repositories:"
|
||||
echo "======================"
|
||||
echo "$repos" | jsonfilter -e '@.data[*]' 2>/dev/null | while read repo; do
|
||||
local name=$(echo "$repo" | jsonfilter -e '@.full_name' 2>/dev/null)
|
||||
local url=$(echo "$repo" | jsonfilter -e '@.original_url' 2>/dev/null)
|
||||
local updated=$(echo "$repo" | jsonfilter -e '@.updated_at' 2>/dev/null)
|
||||
echo " $name"
|
||||
echo " Source: $url"
|
||||
echo " Updated: $updated"
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
cmd_mirror_create() {
|
||||
load_config
|
||||
|
||||
local name=""
|
||||
local url=""
|
||||
local owner=""
|
||||
|
||||
# Parse arguments
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--url) url="$2"; shift 2 ;;
|
||||
--owner) owner="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$name" ] || [ -z "$url" ]; then
|
||||
log_error "Usage: giteactl mirror-create --name <repo-name> --url <source-url> [--owner <user>]"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Gitea container is not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get default owner if not specified
|
||||
if [ -z "$owner" ]; then
|
||||
owner=$(gitea_api GET "/user" 2>/dev/null | jsonfilter -e '@.login' 2>/dev/null)
|
||||
if [ -z "$owner" ]; then
|
||||
log_error "Could not determine owner. Specify with --owner"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Creating mirror repository: $owner/$name from $url"
|
||||
|
||||
local data=$(cat <<EOF
|
||||
{
|
||||
"clone_addr": "$url",
|
||||
"repo_name": "$name",
|
||||
"mirror": true,
|
||||
"private": false,
|
||||
"description": "Mirror of $url"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local result
|
||||
result=$(gitea_api POST "/repos/migrate" "$data" 2>&1)
|
||||
|
||||
if echo "$result" | grep -q '"id":'; then
|
||||
log_info "Mirror created successfully: $owner/$name"
|
||||
log_info "First sync in progress..."
|
||||
else
|
||||
log_error "Failed to create mirror: $result"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_repo_list() {
|
||||
load_config
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Gitea container is not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local repos
|
||||
repos=$(gitea_api GET "/repos/search?limit=100" 2>/dev/null)
|
||||
|
||||
if [ -z "$repos" ]; then
|
||||
echo "No repositories found (or API token not set)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Repositories:"
|
||||
echo "============="
|
||||
echo "$repos" | jsonfilter -e '@.data[*].full_name' 2>/dev/null | while read name; do
|
||||
local is_mirror=$(echo "$repos" | jsonfilter -e "@.data[?(@.full_name=='$name')].mirror" 2>/dev/null)
|
||||
if [ "$is_mirror" = "true" ]; then
|
||||
echo " [mirror] $name"
|
||||
else
|
||||
echo " $name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cmd_service_run() {
|
||||
require_root
|
||||
load_config
|
||||
@ -751,7 +989,11 @@ case "${1:-}" in
|
||||
*) echo "Usage: giteactl admin create-user --username <name> --password <pass> --email <email>"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
service-run) shift; cmd_service_run "$@" ;;
|
||||
service-stop) shift; cmd_service_stop "$@" ;;
|
||||
*) usage ;;
|
||||
mirror-sync) shift; cmd_mirror_sync "$@" ;;
|
||||
mirror-list) shift; cmd_mirror_list "$@" ;;
|
||||
mirror-create) shift; cmd_mirror_create "$@" ;;
|
||||
repo-list) shift; cmd_repo_list "$@" ;;
|
||||
service-run) shift; cmd_service_run "$@" ;;
|
||||
service-stop) shift; cmd_service_stop "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-app-haproxy
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=14
|
||||
PKG_RELEASE:=18
|
||||
|
||||
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
||||
PKG_LICENSE:=MIT
|
||||
|
||||
@ -246,6 +246,17 @@ echo "Config: $CONFIG_FILE"
|
||||
ls -la /opt/haproxy/
|
||||
ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir"
|
||||
|
||||
# Clean up legacy certificate files - only .pem files should exist
|
||||
# HAProxy loads all files from certs directory, and extra files cause errors
|
||||
if [ -d "/opt/haproxy/certs" ]; then
|
||||
for pem in /opt/haproxy/certs/*.pem; do
|
||||
[ -f "$pem" ] || continue
|
||||
base="${pem%.pem}"
|
||||
# Remove any associated .crt, .key, .fullchain.pem, .crt.key files
|
||||
rm -f "${base}.crt" "${base}.key" "${base}.crt.key" "${base}.fullchain.pem" 2>/dev/null
|
||||
done
|
||||
fi
|
||||
|
||||
# Wait for config
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "[haproxy] Config not found, generating default..."
|
||||
@ -503,10 +514,26 @@ _generate_backend() {
|
||||
|
||||
[ -n "$health_check" ] && echo " option $health_check"
|
||||
|
||||
# Add servers defined in backend section (handles both single and list)
|
||||
local server_line
|
||||
config_get server_line "$section" server ""
|
||||
[ -n "$server_line" ] && echo " server $server_line"
|
||||
# Check if there are separate server sections for this backend
|
||||
local has_server_sections=0
|
||||
_check_server_sections() {
|
||||
local srv_section="$1"
|
||||
local srv_backend
|
||||
config_get srv_backend "$srv_section" backend
|
||||
config_get srv_enabled "$srv_section" enabled "0"
|
||||
if [ "$srv_backend" = "$name" ] && [ "$srv_enabled" = "1" ]; then
|
||||
has_server_sections=1
|
||||
fi
|
||||
}
|
||||
config_foreach _check_server_sections server
|
||||
|
||||
# Add inline server ONLY if no separate server sections exist
|
||||
# This prevents duplicate server names
|
||||
if [ "$has_server_sections" = "0" ]; then
|
||||
local server_line
|
||||
config_get server_line "$section" server ""
|
||||
[ -n "$server_line" ] && echo " server $server_line"
|
||||
fi
|
||||
|
||||
# Add servers from separate server UCI sections
|
||||
config_foreach _add_server_to_backend server "$name"
|
||||
@ -541,6 +568,111 @@ _add_server_to_backend() {
|
||||
# Certificate Management
|
||||
# ===========================================
|
||||
|
||||
# Check if certificate is from Let's Encrypt Production (not Staging)
|
||||
cert_is_production() {
|
||||
local cert_file="$1"
|
||||
[ -f "$cert_file" ] || return 1
|
||||
|
||||
# Check the issuer - staging certs have "(STAGING)" in the issuer
|
||||
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null)
|
||||
if echo "$issuer" | grep -qi "staging\|test\|fake"; then
|
||||
return 1 # Staging certificate
|
||||
fi
|
||||
|
||||
# Check for Let's Encrypt production issuers
|
||||
if echo "$issuer" | grep -qiE "Let's Encrypt|R3|R10|R11|E1|E2|ISRG"; then
|
||||
return 0 # Production certificate
|
||||
fi
|
||||
|
||||
# Check if it's a self-signed or other CA
|
||||
return 0 # Assume production for other CAs
|
||||
}
|
||||
|
||||
# Validate certificate publicly using external service
|
||||
cert_validate_public() {
|
||||
local domain="$1"
|
||||
local timeout=10
|
||||
|
||||
# Try to connect and verify the certificate
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -sS --max-time "$timeout" -o /dev/null "https://$domain" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: use openssl s_client
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
local result=$(echo | timeout "$timeout" openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null)
|
||||
if [ -n "$result" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get certificate info
|
||||
cert_info() {
|
||||
local cert_file="$1"
|
||||
[ -f "$cert_file" ] || return 1
|
||||
|
||||
local subject=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/subject=//')
|
||||
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/issuer=//')
|
||||
local not_after=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
local not_before=$(openssl x509 -in "$cert_file" -noout -startdate 2>/dev/null | cut -d= -f2)
|
||||
|
||||
echo "Subject: $subject"
|
||||
echo "Issuer: $issuer"
|
||||
echo "Valid From: $not_before"
|
||||
echo "Valid Until: $not_after"
|
||||
|
||||
if cert_is_production "$cert_file"; then
|
||||
echo "Type: PRODUCTION (publicly trusted)"
|
||||
else
|
||||
echo "Type: STAGING/TEST (NOT publicly trusted!)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify and report certificate status
|
||||
cmd_cert_verify() {
|
||||
load_config
|
||||
|
||||
local domain="$1"
|
||||
if [ -z "$domain" ]; then
|
||||
echo "Usage: haproxyctl cert verify <domain>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cert_file="$CERTS_PATH/$domain.pem"
|
||||
if [ ! -f "$cert_file" ]; then
|
||||
log_error "Certificate not found: $cert_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Certificate Information for $domain:"
|
||||
echo "======================================"
|
||||
cert_info "$cert_file"
|
||||
echo ""
|
||||
|
||||
# Check if it's production
|
||||
if ! cert_is_production "$cert_file"; then
|
||||
log_warn "This is a STAGING certificate - NOT trusted by browsers!"
|
||||
log_warn "To get a production certificate, ensure staging='0' in config and re-issue"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Try public validation
|
||||
echo "Public Validation:"
|
||||
if cert_validate_public "$domain"; then
|
||||
log_info "Certificate is publicly valid and accessible"
|
||||
return 0
|
||||
else
|
||||
log_warn "Could not verify certificate publicly"
|
||||
log_warn "Ensure DNS points to this server and port 443 is accessible"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_cert_list() {
|
||||
load_config
|
||||
|
||||
@ -552,11 +684,25 @@ cmd_cert_list() {
|
||||
[ -f "$cert" ] || continue
|
||||
local name=$(basename "$cert" .pem)
|
||||
local expiry=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
echo " $name - Expires: ${expiry:-Unknown}"
|
||||
local type_icon="✅"
|
||||
if ! cert_is_production "$cert"; then
|
||||
type_icon="⚠️ STAGING"
|
||||
fi
|
||||
echo " $name - Expires: ${expiry:-Unknown} $type_icon"
|
||||
done
|
||||
else
|
||||
echo " No certificates found"
|
||||
fi
|
||||
|
||||
# Show current mode
|
||||
local staging=$(uci -q get haproxy.acme.staging)
|
||||
echo ""
|
||||
if [ "$staging" = "1" ]; then
|
||||
echo "⚠️ ACME Mode: STAGING (certificates will NOT be trusted by browsers)"
|
||||
echo " To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
|
||||
else
|
||||
echo "✅ ACME Mode: PRODUCTION (certificates will be publicly trusted)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_cert_add() {
|
||||
@ -579,6 +725,18 @@ cmd_cert_add() {
|
||||
|
||||
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
|
||||
|
||||
# Warn about staging mode
|
||||
if [ "$staging" = "1" ]; then
|
||||
log_warn "=========================================="
|
||||
log_warn "STAGING MODE ENABLED!"
|
||||
log_warn "Certificate will NOT be trusted by browsers"
|
||||
log_warn "To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
|
||||
log_warn "=========================================="
|
||||
sleep 2
|
||||
else
|
||||
log_info "Using Let's Encrypt PRODUCTION (certificates will be publicly trusted)"
|
||||
fi
|
||||
|
||||
log_info "Requesting certificate for $domain..."
|
||||
|
||||
local staging_flag=""
|
||||
@ -637,6 +795,10 @@ cmd_cert_add() {
|
||||
log_info "Creating combined PEM for HAProxy..."
|
||||
cat "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.key" > "$CERTS_PATH/$domain.pem"
|
||||
chmod 600 "$CERTS_PATH/$domain.pem"
|
||||
|
||||
# Clean up intermediate files - HAProxy only needs the .pem file
|
||||
# Keeping these causes issues when HAProxy loads certs from directory
|
||||
rm -f "$CERTS_PATH/$domain.crt" "$CERTS_PATH/$domain.key" "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.crt.key" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Restart HAProxy if it was running
|
||||
@ -669,6 +831,24 @@ cmd_cert_add() {
|
||||
|
||||
chmod 600 "$CERTS_PATH/$domain.pem"
|
||||
|
||||
# Verify certificate type (production vs staging)
|
||||
echo ""
|
||||
if cert_is_production "$CERTS_PATH/$domain.pem"; then
|
||||
log_info "✅ Certificate is from PRODUCTION CA (publicly trusted)"
|
||||
else
|
||||
log_warn "⚠️ Certificate is from STAGING CA (NOT publicly trusted!)"
|
||||
log_warn " Browsers will show security warnings for this certificate"
|
||||
log_warn " To get a production certificate:"
|
||||
log_warn " 1. uci set haproxy.acme.staging='0'"
|
||||
log_warn " 2. uci commit haproxy"
|
||||
log_warn " 3. haproxyctl cert remove $domain"
|
||||
log_warn " 4. haproxyctl cert add $domain"
|
||||
fi
|
||||
|
||||
# Show certificate info
|
||||
echo ""
|
||||
cert_info "$CERTS_PATH/$domain.pem"
|
||||
|
||||
# Add to UCI
|
||||
local section="cert_$(echo "$domain" | tr '.-' '__')"
|
||||
uci set haproxy.$section=certificate
|
||||
@ -678,6 +858,13 @@ cmd_cert_add() {
|
||||
uci commit haproxy
|
||||
|
||||
log_info "Certificate installed for $domain"
|
||||
|
||||
# Offer to verify publicly if production
|
||||
if cert_is_production "$CERTS_PATH/$domain.pem"; then
|
||||
echo ""
|
||||
log_info "To verify the certificate is working publicly, run:"
|
||||
log_info " haproxyctl cert verify $domain"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_cert_import() {
|
||||
@ -1021,8 +1208,9 @@ case "${1:-}" in
|
||||
add) shift; cmd_cert_add "$@" ;;
|
||||
import) shift; cmd_cert_import "$@" ;;
|
||||
renew) shift; cmd_cert_add "$@" ;;
|
||||
verify) shift; cmd_cert_verify "$@" ;;
|
||||
remove) shift; rm -f "$CERTS_PATH/$1.pem"; uci delete haproxy.cert_${1//[.-]/_} 2>/dev/null ;;
|
||||
*) echo "Usage: haproxyctl cert {list|add|import|renew|remove}" ;;
|
||||
*) echo "Usage: haproxyctl cert {list|add|import|renew|verify|remove}" ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ define Package/secubox-app-magicmirror2/postinst
|
||||
echo " mm2ctl install"
|
||||
echo " /etc/init.d/magicmirror2 start"
|
||||
echo ""
|
||||
echo "Web interface: http://<router-ip>:8082"
|
||||
echo "Web interface: http://<router-ip>:8085"
|
||||
echo ""
|
||||
echo "To manage modules:"
|
||||
echo " mm2ctl module list"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
config magicmirror2 'main'
|
||||
option enabled '0'
|
||||
option port '8082'
|
||||
option port '8085'
|
||||
option address '0.0.0.0'
|
||||
option data_path '/srv/magicmirror2'
|
||||
option memory_limit '512M'
|
||||
|
||||
@ -51,7 +51,7 @@ Examples:
|
||||
mm2ctl module list
|
||||
mm2ctl config
|
||||
|
||||
Web Interface: http://<router-ip>:8082
|
||||
Web Interface: http://<router-ip>:8085
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
|
||||
|
||||
# Load configuration with defaults
|
||||
load_config() {
|
||||
port="$(uci_get main.port || echo 8082)"
|
||||
port="$(uci_get main.port || echo 8085)"
|
||||
address="$(uci_get main.address || echo 0.0.0.0)"
|
||||
data_path="$(uci_get main.data_path || echo /srv/magicmirror2)"
|
||||
memory_limit="$(uci_get main.memory_limit || echo 512M)"
|
||||
@ -255,7 +255,7 @@ lxc_create_docker_rootfs() {
|
||||
cat >> "$rootfs/opt/start-mm2.sh" << 'START'
|
||||
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||
export NODE_ENV=production
|
||||
export MM_PORT="${MM2_PORT:-8082}"
|
||||
export MM_PORT="${MM2_PORT:-8085}"
|
||||
export MM_ADDRESS="${MM2_ADDRESS:-0.0.0.0}"
|
||||
|
||||
MM_DIR="/opt/magic_mirror"
|
||||
|
||||
@ -18,10 +18,10 @@ config cms 'cms'
|
||||
config hexo 'hexo'
|
||||
option source_path '/srv/hexojs/site/source/_posts'
|
||||
option public_path '/srv/hexojs/site/public'
|
||||
option portal_path '/www'
|
||||
option auto_publish '0'
|
||||
option portal_path '/www/blog'
|
||||
option auto_publish '1'
|
||||
|
||||
config portal 'portal'
|
||||
option enabled '1'
|
||||
option url_path '/'
|
||||
option url_path '/blog'
|
||||
option title 'SecuBox Blog'
|
||||
|
||||
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-app-streamlit
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_RELEASE:=2
|
||||
PKG_ARCH:=all
|
||||
|
||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
||||
|
||||
@ -187,7 +187,7 @@ cd /srv/apps
|
||||
exec streamlit run "$APP_PATH" \
|
||||
--server.address="${STREAMLIT_HOST:-0.0.0.0}" \
|
||||
--server.port="${STREAMLIT_PORT:-8501}" \
|
||||
--server.headless="${STREAMLIT_HEADLESS:-true}" \
|
||||
--server.headless=true \
|
||||
--browser.gatherUsageStats="${STREAMLIT_STATS:-false}" \
|
||||
--theme.base="${STREAMLIT_THEME_BASE:-dark}" \
|
||||
--theme.primaryColor="${STREAMLIT_THEME_PRIMARY:-#0ff}"
|
||||
|
||||
@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-core
|
||||
PKG_VERSION:=0.10.0
|
||||
PKG_RELEASE:=4
|
||||
PKG_RELEASE:=5
|
||||
PKG_ARCH:=all
|
||||
PKG_LICENSE:=GPL-2.0
|
||||
PKG_MAINTAINER:=SecuBox Team
|
||||
|
||||
@ -71,6 +71,15 @@ case "$1" in
|
||||
json_add_object "getHealth"
|
||||
json_close_object
|
||||
|
||||
json_add_object "get_network_health"
|
||||
json_close_object
|
||||
|
||||
json_add_object "get_vital_services"
|
||||
json_close_object
|
||||
|
||||
json_add_object "get_full_health_report"
|
||||
json_close_object
|
||||
|
||||
json_add_object "getLogs"
|
||||
json_add_string "service" "string"
|
||||
json_add_int "lines" "integer"
|
||||
@ -330,18 +339,385 @@ case "$1" in
|
||||
/usr/sbin/secubox-core health
|
||||
;;
|
||||
|
||||
get_network_health)
|
||||
# Network health monitoring - detects CRC errors, link flapping
|
||||
DMESG_LINES=500
|
||||
FLAP_THRESHOLD=5
|
||||
CRC_THRESHOLD=10
|
||||
|
||||
json_init
|
||||
json_add_string "timestamp" "$(date -Iseconds)"
|
||||
json_add_object "interfaces"
|
||||
|
||||
overall="healthy"
|
||||
critical_count=0
|
||||
warning_count=0
|
||||
|
||||
for iface_path in /sys/class/net/eth* /sys/class/net/wan* /sys/class/net/lan*; do
|
||||
[ -d "$iface_path" ] || continue
|
||||
[ -d "$iface_path/device" ] || continue
|
||||
iface=$(basename "$iface_path")
|
||||
|
||||
current_state=$(cat "$iface_path/operstate" 2>/dev/null || echo "unknown")
|
||||
crc_count=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface.*crc error" 2>/dev/null)
|
||||
crc_count=${crc_count:-0}
|
||||
link_up=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Up" 2>/dev/null)
|
||||
link_up=${link_up:-0}
|
||||
link_down=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Down" 2>/dev/null)
|
||||
link_down=${link_down:-0}
|
||||
link_changes=$((link_up + link_down))
|
||||
|
||||
status="ok"
|
||||
issues=""
|
||||
|
||||
if [ "$crc_count" -ge "$CRC_THRESHOLD" ]; then
|
||||
status="critical"
|
||||
issues="CRC errors ($crc_count)"
|
||||
critical_count=$((critical_count + 1))
|
||||
fi
|
||||
|
||||
if [ "$link_changes" -ge "$FLAP_THRESHOLD" ]; then
|
||||
[ "$status" = "ok" ] && status="warning"
|
||||
[ -n "$issues" ] && issues="$issues; "
|
||||
issues="${issues}Link flapping ($link_changes changes)"
|
||||
warning_count=$((warning_count + 1))
|
||||
fi
|
||||
|
||||
rx_errors=$(cat "$iface_path/statistics/rx_errors" 2>/dev/null || echo 0)
|
||||
tx_errors=$(cat "$iface_path/statistics/tx_errors" 2>/dev/null || echo 0)
|
||||
|
||||
json_add_object "$iface"
|
||||
json_add_string "status" "$status"
|
||||
json_add_string "state" "$current_state"
|
||||
json_add_int "crc_errors" "$crc_count"
|
||||
json_add_int "link_changes" "$link_changes"
|
||||
json_add_int "rx_errors" "$rx_errors"
|
||||
json_add_int "tx_errors" "$tx_errors"
|
||||
json_add_string "issues" "$issues"
|
||||
json_close_object
|
||||
done
|
||||
|
||||
json_close_object
|
||||
|
||||
if [ "$critical_count" -gt 0 ]; then
|
||||
overall="critical"
|
||||
elif [ "$warning_count" -gt 0 ]; then
|
||||
overall="warning"
|
||||
fi
|
||||
|
||||
json_add_string "overall" "$overall"
|
||||
json_add_int "critical_interfaces" "$critical_count"
|
||||
json_add_int "warning_interfaces" "$warning_count"
|
||||
|
||||
if [ "$overall" != "healthy" ]; then
|
||||
json_add_array "recommendations"
|
||||
[ "$critical_count" -gt 0 ] && json_add_string "" "Check/replace Ethernet cables"
|
||||
[ "$critical_count" -gt 0 ] && json_add_string "" "Try different port on switch/modem"
|
||||
[ "$warning_count" -gt 0 ] && json_add_string "" "Monitor link stability"
|
||||
json_close_array
|
||||
fi
|
||||
|
||||
json_dump
|
||||
;;
|
||||
|
||||
get_vital_services)
|
||||
# Vital services monitoring for web hosting and remote management
|
||||
json_init
|
||||
json_add_string "timestamp" "$(date -Iseconds)"
|
||||
|
||||
# Helper function to check service
|
||||
check_service() {
|
||||
local name="$1"
|
||||
local category="$2"
|
||||
local check_type="$3"
|
||||
local check_value="$4"
|
||||
local description="$5"
|
||||
local critical="$6"
|
||||
|
||||
local status="unknown"
|
||||
local details=""
|
||||
|
||||
case "$check_type" in
|
||||
process)
|
||||
if pgrep -f "$check_value" >/dev/null 2>&1; then
|
||||
status="running"
|
||||
else
|
||||
status="stopped"
|
||||
fi
|
||||
;;
|
||||
port)
|
||||
if netstat -tln 2>/dev/null | grep -q ":${check_value} "; then
|
||||
status="running"
|
||||
details="Port $check_value listening"
|
||||
else
|
||||
status="stopped"
|
||||
details="Port $check_value not listening"
|
||||
fi
|
||||
;;
|
||||
init)
|
||||
if [ -f "/etc/init.d/$check_value" ]; then
|
||||
if /etc/init.d/$check_value enabled 2>/dev/null; then
|
||||
if /etc/init.d/$check_value running 2>/dev/null; then
|
||||
status="running"
|
||||
else
|
||||
status="stopped"
|
||||
fi
|
||||
else
|
||||
status="disabled"
|
||||
fi
|
||||
else
|
||||
status="not_installed"
|
||||
fi
|
||||
;;
|
||||
lxc)
|
||||
if lxc-info -n "$check_value" -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
status="running"
|
||||
elif lxc-info -n "$check_value" 2>/dev/null | grep -q "State"; then
|
||||
status="stopped"
|
||||
else
|
||||
status="not_installed"
|
||||
fi
|
||||
;;
|
||||
file)
|
||||
if [ -f "$check_value" ]; then
|
||||
status="present"
|
||||
else
|
||||
status="missing"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "name" "$name"
|
||||
json_add_string "category" "$category"
|
||||
json_add_string "status" "$status"
|
||||
json_add_string "description" "$description"
|
||||
json_add_boolean "critical" "${critical:-0}"
|
||||
[ -n "$details" ] && json_add_string "details" "$details"
|
||||
json_close_object
|
||||
}
|
||||
|
||||
# Core Infrastructure Services
|
||||
json_add_array "core"
|
||||
check_service "SSH" "remote" "port" "22" "Remote shell access" 1
|
||||
check_service "HTTPS Admin" "remote" "port" "8444" "LuCI admin interface" 1
|
||||
check_service "DNS" "network" "port" "53" "Domain name resolution" 1
|
||||
check_service "DHCP" "network" "process" "dnsmasq" "IP address assignment" 1
|
||||
check_service "Firewall" "security" "process" "fw4" "Network firewall" 1
|
||||
json_close_array
|
||||
|
||||
# Security Services
|
||||
json_add_array "security"
|
||||
check_service "CrowdSec" "security" "process" "crowdsec" "Intrusion prevention" 1
|
||||
check_service "CrowdSec Bouncer" "security" "process" "crowdsec-firewall-bouncer" "Firewall bouncer" 1
|
||||
check_service "Tor" "privacy" "init" "tor" "Anonymous routing" 0
|
||||
json_close_array
|
||||
|
||||
# Web Publishing Services
|
||||
json_add_array "publishers"
|
||||
check_service "HAProxy" "proxy" "lxc" "haproxy" "Load balancer & reverse proxy" 1
|
||||
check_service "HexoJS" "cms" "lxc" "hexojs" "Static blog generator" 0
|
||||
check_service "Gitea" "devops" "lxc" "gitea" "Git repository hosting" 0
|
||||
check_service "Streamlit" "app" "lxc" "streamlit" "Python web apps" 0
|
||||
json_close_array
|
||||
|
||||
# Media & App Services
|
||||
json_add_array "apps"
|
||||
check_service "Lyrion" "media" "lxc" "lyrion" "Music streaming server" 0
|
||||
check_service "MagicMirror" "display" "lxc" "magicmirror2" "Smart mirror display" 0
|
||||
check_service "PicoBrew" "app" "lxc" "picobrew" "Brewing automation" 0
|
||||
json_close_array
|
||||
|
||||
# Monitoring Services
|
||||
json_add_array "monitoring"
|
||||
check_service "Netifyd" "monitoring" "process" "netifyd" "Network intelligence" 0
|
||||
check_service "Syslog-ng" "logging" "process" "syslog-ng" "System logging" 1
|
||||
json_close_array
|
||||
|
||||
# Calculate summary
|
||||
json_add_object "summary"
|
||||
total=0
|
||||
running=0
|
||||
stopped=0
|
||||
critical_down=0
|
||||
|
||||
for svc in /etc/init.d/*; do
|
||||
[ -x "$svc" ] || continue
|
||||
total=$((total + 1))
|
||||
done
|
||||
|
||||
# Count running LXC containers
|
||||
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
|
||||
lxc_total=$(lxc-ls 2>/dev/null | wc -w)
|
||||
|
||||
json_add_int "init_services" "$total"
|
||||
json_add_int "lxc_running" "$lxc_running"
|
||||
json_add_int "lxc_total" "$lxc_total"
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
;;
|
||||
|
||||
get_full_health_report)
|
||||
# Combined health report: network + services + system
|
||||
json_init
|
||||
json_add_string "timestamp" "$(date -Iseconds)"
|
||||
json_add_string "hostname" "$(uci get system.@system[0].hostname 2>/dev/null || hostname)"
|
||||
|
||||
# System info
|
||||
json_add_object "system"
|
||||
json_add_int "uptime" "$(cut -d. -f1 /proc/uptime)"
|
||||
json_add_string "load" "$(cut -d' ' -f1-3 /proc/loadavg)"
|
||||
|
||||
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
|
||||
mem_avail=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
|
||||
mem_avail=${mem_avail:-0}
|
||||
mem_used=$((mem_total - mem_avail))
|
||||
mem_pct=$((mem_used * 100 / mem_total))
|
||||
json_add_int "memory_percent" "$mem_pct"
|
||||
|
||||
disk_pct=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||
json_add_int "disk_percent" "${disk_pct:-0}"
|
||||
json_close_object
|
||||
|
||||
# Network Health Summary
|
||||
json_add_object "network"
|
||||
net_overall="healthy"
|
||||
net_issues=0
|
||||
|
||||
for iface_path in /sys/class/net/eth* /sys/class/net/wan*; do
|
||||
[ -d "$iface_path" ] || continue
|
||||
[ -d "$iface_path/device" ] || continue
|
||||
iface=$(basename "$iface_path")
|
||||
|
||||
crc=$(dmesg | tail -n 500 | grep -c "$iface.*crc error" 2>/dev/null)
|
||||
crc=${crc:-0}
|
||||
flap=$(dmesg | tail -n 500 | grep -c "$iface: Link is" 2>/dev/null)
|
||||
flap=${flap:-0}
|
||||
|
||||
if [ "$crc" -ge 10 ] || [ "$flap" -ge 10 ]; then
|
||||
net_overall="critical"
|
||||
net_issues=$((net_issues + 1))
|
||||
json_add_object "$iface"
|
||||
json_add_string "status" "critical"
|
||||
json_add_int "crc_errors" "$crc"
|
||||
json_add_int "link_changes" "$flap"
|
||||
json_close_object
|
||||
fi
|
||||
done
|
||||
|
||||
json_add_string "overall" "$net_overall"
|
||||
json_add_int "issues" "$net_issues"
|
||||
json_close_object
|
||||
|
||||
# Critical Services Status
|
||||
json_add_object "services"
|
||||
svc_ok=0
|
||||
svc_down=0
|
||||
|
||||
# Check critical services
|
||||
for svc in sshd dropbear dnsmasq haproxy crowdsec; do
|
||||
if pgrep -x "$svc" >/dev/null 2>&1 || pgrep -f "$svc" >/dev/null 2>&1; then
|
||||
svc_ok=$((svc_ok + 1))
|
||||
else
|
||||
# Check if it's supposed to be running
|
||||
if [ -f "/etc/init.d/$svc" ] && /etc/init.d/$svc enabled 2>/dev/null; then
|
||||
svc_down=$((svc_down + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Check LXC containers
|
||||
lxc_expected=$(lxc-ls 2>/dev/null | wc -w)
|
||||
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
|
||||
|
||||
json_add_int "services_ok" "$svc_ok"
|
||||
json_add_int "services_down" "$svc_down"
|
||||
json_add_int "containers_running" "$lxc_running"
|
||||
json_add_int "containers_total" "$lxc_expected"
|
||||
|
||||
if [ "$svc_down" -gt 0 ]; then
|
||||
json_add_string "overall" "warning"
|
||||
else
|
||||
json_add_string "overall" "healthy"
|
||||
fi
|
||||
json_close_object
|
||||
|
||||
# Overall health score
|
||||
health_score=100
|
||||
[ "$net_overall" = "critical" ] && health_score=$((health_score - 30))
|
||||
[ "$svc_down" -gt 0 ] && health_score=$((health_score - (svc_down * 10)))
|
||||
[ "$mem_pct" -gt 90 ] && health_score=$((health_score - 10))
|
||||
[ "${disk_pct:-0}" -gt 90 ] && health_score=$((health_score - 10))
|
||||
|
||||
json_add_int "health_score" "$health_score"
|
||||
|
||||
if [ "$health_score" -ge 80 ]; then
|
||||
json_add_string "overall_status" "healthy"
|
||||
elif [ "$health_score" -ge 50 ]; then
|
||||
json_add_string "overall_status" "warning"
|
||||
else
|
||||
json_add_string "overall_status" "critical"
|
||||
fi
|
||||
|
||||
# Alerts
|
||||
json_add_array "alerts"
|
||||
[ "$net_overall" = "critical" ] && {
|
||||
json_add_object ""
|
||||
json_add_string "level" "critical"
|
||||
json_add_string "message" "Network interface issues detected - check cables"
|
||||
json_close_object
|
||||
}
|
||||
[ "$svc_down" -gt 0 ] && {
|
||||
json_add_object ""
|
||||
json_add_string "level" "warning"
|
||||
json_add_string "message" "$svc_down critical service(s) not running"
|
||||
json_close_object
|
||||
}
|
||||
[ "$mem_pct" -gt 90 ] && {
|
||||
json_add_object ""
|
||||
json_add_string "level" "warning"
|
||||
json_add_string "message" "High memory usage: ${mem_pct}%"
|
||||
json_close_object
|
||||
}
|
||||
json_close_array
|
||||
|
||||
json_dump
|
||||
;;
|
||||
|
||||
get_dashboard_data)
|
||||
# Return dashboard summary data
|
||||
# Return dashboard summary data (OPTIMIZED - no slow appstore call)
|
||||
json_init
|
||||
|
||||
# Get module stats
|
||||
modules_output=$(/usr/sbin/secubox-appstore list --json 2>/dev/null || echo '{"modules":[]}')
|
||||
total_modules=$(echo "$modules_output" | jsonfilter -e '@.modules[*]' | wc -l)
|
||||
running_modules=$(echo "$modules_output" | jsonfilter -e '@.modules[@.state="running"]' | wc -l 2>/dev/null || echo 0)
|
||||
# Fast module counting: count installed secubox packages
|
||||
# This avoids the slow secubox-appstore list --json call
|
||||
total_modules=0
|
||||
running_modules=0
|
||||
|
||||
# Count from catalog (fast - just count JSON entries)
|
||||
CATALOG_FILE="/usr/share/secubox/catalog.json"
|
||||
if [ -f "$CATALOG_FILE" ]; then
|
||||
total_modules=$(jsonfilter -i "$CATALOG_FILE" -e '@.plugins[*].id' 2>/dev/null | wc -l)
|
||||
fi
|
||||
[ -z "$total_modules" ] || [ "$total_modules" -eq 0 ] && total_modules=0
|
||||
|
||||
# Count running LXC containers (fast)
|
||||
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
|
||||
lxc_running=${lxc_running:-0}
|
||||
|
||||
# Count running init services that are SecuBox-related (fast)
|
||||
svc_running=0
|
||||
for svc in crowdsec tor haproxy netifyd syslog-ng; do
|
||||
if pgrep -f "$svc" >/dev/null 2>&1; then
|
||||
svc_running=$((svc_running + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
running_modules=$((lxc_running + svc_running))
|
||||
|
||||
# Get system info
|
||||
uptime_seconds=$(cat /proc/uptime | cut -d' ' -f1 | cut -d'.' -f1)
|
||||
load_avg=$(cat /proc/loadavg | cut -d' ' -f1-3)
|
||||
uptime_seconds=$(cut -d' ' -f1 /proc/uptime | cut -d'.' -f1)
|
||||
load_avg=$(cut -d' ' -f1-3 /proc/loadavg)
|
||||
|
||||
# Build response
|
||||
json_add_object "status"
|
||||
@ -353,6 +729,8 @@ case "$1" in
|
||||
json_add_object "counts"
|
||||
json_add_int "total" "$total_modules"
|
||||
json_add_int "running" "$running_modules"
|
||||
json_add_int "lxc_running" "$lxc_running"
|
||||
json_add_int "services_running" "$svc_running"
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
@ -1198,18 +1576,21 @@ case "$1" in
|
||||
|
||||
while read port local proc; do
|
||||
addr=$(echo "$local" | sed 's/:[^:]*$//')
|
||||
name="Service"; icon=""; category="other"; path=""
|
||||
name=""; icon=""; category="other"; path=""
|
||||
|
||||
# First: identify by well-known port (most reliable for multi-service ports)
|
||||
case "$port" in
|
||||
22) name="SSH"; icon="lock"; category="system" ;;
|
||||
53) name="DNS"; icon="globe"; category="system" ;;
|
||||
80) name="HTTP"; icon="arrow"; path="/"; category="proxy" ;;
|
||||
443) name="HTTPS"; icon="shield"; path="/"; category="proxy" ;;
|
||||
2222) name="Gitea SSH"; icon="git"; category="app" ;;
|
||||
3000) name="Gitea"; icon="git"; path=":3000"; category="app" ;;
|
||||
3483) name="Squeezebox"; icon="music"; category="media" ;;
|
||||
4000) name="HexoJS"; icon="blog"; path=":4000"; category="app" ;;
|
||||
8080) name="CrowdSec"; icon="security"; category="security" ;;
|
||||
6060) name="CrowdSec LAPI"; icon="security"; category="security" ;;
|
||||
8081) name="LuCI"; icon="settings"; path=":8081"; category="system" ;;
|
||||
8082) name="CyberFeed"; icon="feed"; path=":8082"; category="app" ;;
|
||||
8085) name="MagicMirror2"; icon="app"; path=":8085"; category="app" ;;
|
||||
8086) name="Netifyd"; icon="chart"; path=":8086"; category="monitoring" ;;
|
||||
8404) name="HAProxy Stats"; icon="stats"; path=":8404/stats"; category="monitoring" ;;
|
||||
8444) name="LuCI HTTPS"; icon="admin"; path=":8444"; category="system" ;;
|
||||
@ -1217,10 +1598,31 @@ case "$1" in
|
||||
9000) name="Lyrion"; icon="music"; path=":9000"; category="media" ;;
|
||||
9050) name="Tor SOCKS"; icon="onion"; category="privacy" ;;
|
||||
9090) name="Lyrion CLI"; icon="music"; category="media" ;;
|
||||
2222) name="Gitea SSH"; icon="git"; category="app" ;;
|
||||
3483) name="Squeezebox"; icon="music"; category="media" ;;
|
||||
esac
|
||||
|
||||
# Fallback: identify by process name if port didn't match
|
||||
if [ -z "$name" ]; then
|
||||
case "$proc" in
|
||||
sshd|dropbear) name="SSH"; icon="lock"; category="system" ;;
|
||||
dnsmasq|named|unbound) name="DNS"; icon="globe"; category="system" ;;
|
||||
haproxy) name="HAProxy"; icon="arrow"; category="proxy" ;;
|
||||
nginx|uhttpd) name="Web Server"; icon="settings"; category="system" ;;
|
||||
gitea) name="Gitea"; icon="git"; path=":$port"; category="app" ;;
|
||||
hexo|node) name="HexoJS"; icon="blog"; path=":$port"; category="app" ;;
|
||||
crowdsec|lapi) name="CrowdSec"; icon="security"; category="security" ;;
|
||||
netifyd) name="Netifyd"; icon="chart"; path=":$port"; category="monitoring" ;;
|
||||
slimserver|squeezeboxserver) name="Lyrion"; icon="music"; path=":$port"; category="media" ;;
|
||||
tor) name="Tor"; icon="onion"; category="privacy" ;;
|
||||
cyberfeed*) name="CyberFeed"; icon="feed"; path=":$port"; category="app" ;;
|
||||
metabolizer*) name="Metabolizer"; icon="blog"; path=":$port"; category="app" ;;
|
||||
magicmirror*|electron) name="MagicMirror"; icon="app"; path=":$port"; category="app" ;;
|
||||
picobrew*) name="PicoBrew"; icon="app"; path=":$port"; category="app" ;;
|
||||
streamlit) name="Streamlit"; icon="app"; path=":$port"; category="app" ;;
|
||||
python*) name="Python App"; icon="app"; path=":$port"; category="app" ;;
|
||||
*) name="$proc"; icon=""; category="other"; path=":$port" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
external=0
|
||||
case "$addr" in 0.0.0.0|::) external=1 ;; 127.0.0.1|::1) ;; *) external=1 ;; esac
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user