feat(haproxy,service-registry): Add async cert workflow and fix QR codes

HAProxy Certificates:
- Add async certificate request API (start_cert_request, get_cert_task)
- Non-blocking ACME requests with background processing
- Real-time progress tracking with phases (starting → validating → requesting → verifying → complete)
- Add staging vs production mode toggle for ACME
- New modern UI with visual progress indicators
- Task persistence and polling support

Service Registry:
- Fix QR codes using api.qrserver.com (Google Charts deprecated)
- Fix form prefill with proper _new section selectors
- Add change event dispatch for LuCI form bindings
- Update landing page generator with working QR API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-28 06:40:57 +01:00
parent 8d08ccd4a4
commit bc5bd8d8ce
6 changed files with 1038 additions and 634 deletions

View File

@ -2,158 +2,371 @@
'require view';
'require dom';
'require ui';
'require rpc';
'require haproxy.api as api';
// Async certificate API
var callStartCertRequest = rpc.declare({
object: 'luci.haproxy',
method: 'start_cert_request',
params: ['domain', 'staging']
});
var callGetCertTask = rpc.declare({
object: 'luci.haproxy',
method: 'get_cert_task',
params: ['task_id']
});
var callListCertTasks = rpc.declare({
object: 'luci.haproxy',
method: 'list_cert_tasks'
});
return view.extend({
pollInterval: null,
currentTaskId: null,
load: function() {
return api.listCertificates();
return Promise.all([
api.listCertificates(),
callListCertTasks().catch(function() { return { tasks: [] }; })
]);
},
render: function(certificates) {
render: function(data) {
var self = this;
certificates = certificates || [];
var certificates = data[0] || [];
var tasks = (data[1] && data[1].tasks) || [];
// Filter active tasks
var activeTasks = tasks.filter(function(t) {
return t.status === 'pending' || t.status === 'running';
});
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'SSL Certificates'),
E('style', {}, this.getStyles()),
E('h2', {}, '🔒 SSL Certificates'),
E('p', {}, 'Manage SSL/TLS certificates for your domains. Request free certificates via ACME or import your own.'),
// Active tasks (if any)
activeTasks.length > 0 ? this.renderActiveTasks(activeTasks) : null,
// Request certificate section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Request Certificate (ACME/Let\'s Encrypt)'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'class': 'cert-section' }, [
E('h3', {}, '📜 Request Certificate (ACME/Let\'s Encrypt)'),
E('div', { 'class': 'cert-form' }, [
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, 'Domain'),
E('input', {
'type': 'text',
'id': 'acme-domain',
'class': 'cbi-input-text',
'placeholder': 'example.com'
}),
E('p', { 'class': 'cbi-value-description' },
'Domain must point to this server. ACME challenge will run on port 80.')
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
E('span', { 'class': 'cert-hint' }, 'Domain must point to this server')
]),
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, 'Mode'),
E('div', { 'class': 'cert-mode-toggle' }, [
E('label', { 'class': 'cert-mode-option' }, [
E('input', {
'type': 'radio',
'name': 'acme-mode',
'value': 'production',
'checked': true
}),
E('span', { 'class': 'cert-mode-label cert-mode-prod' }, '🏭 Production'),
E('span', { 'class': 'cert-mode-desc' }, 'Publicly trusted certificate')
]),
E('label', { 'class': 'cert-mode-option' }, [
E('input', {
'type': 'radio',
'name': 'acme-mode',
'value': 'staging'
}),
E('span', { 'class': 'cert-mode-label cert-mode-staging' }, '🧪 Staging'),
E('span', { 'class': 'cert-mode-desc' }, 'Test certificate (not trusted)')
])
])
]),
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, ''),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': function() { self.handleRequestCert(); }
}, 'Request Certificate')
'id': 'btn-request-cert',
'click': function() { self.handleRequestCertAsync(); }
}, '🚀 Request Certificate')
])
]),
// Progress container (hidden initially)
E('div', { 'id': 'cert-progress-container', 'class': 'cert-progress', 'style': 'display: none;' }, [
E('div', { 'class': 'cert-progress-header' }, [
E('span', { 'id': 'cert-progress-icon', 'class': 'cert-progress-icon' }, '⏳'),
E('span', { 'id': 'cert-progress-domain', 'class': 'cert-progress-domain' }, ''),
E('span', { 'id': 'cert-progress-status', 'class': 'cert-status' }, '')
]),
E('div', { 'class': 'cert-progress-phases' }, [
E('div', { 'id': 'phase-starting', 'class': 'cert-phase' }, '⬜ Starting'),
E('div', { 'id': 'phase-validating', 'class': 'cert-phase' }, '⬜ DNS Validation'),
E('div', { 'id': 'phase-requesting', 'class': 'cert-phase' }, '⬜ ACME Request'),
E('div', { 'id': 'phase-verifying', 'class': 'cert-phase' }, '⬜ Verifying'),
E('div', { 'id': 'phase-complete', 'class': 'cert-phase' }, '⬜ Complete')
]),
E('div', { 'id': 'cert-progress-message', 'class': 'cert-progress-message' }, '')
])
]),
// Import certificate section
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Import Certificate'),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'class': 'cert-section' }, [
E('h3', {}, '📥 Import Certificate'),
E('div', { 'class': 'cert-form' }, [
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, 'Domain'),
E('input', {
'type': 'text',
'id': 'import-domain',
'class': 'cbi-input-text',
'placeholder': 'example.com'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Certificate (PEM)'),
E('div', { 'class': 'cbi-value-field' }, [
]),
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, 'Certificate (PEM)'),
E('textarea', {
'id': 'import-cert',
'class': 'cbi-input-textarea',
'rows': '6',
'rows': '4',
'placeholder': '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Private Key (PEM)'),
E('div', { 'class': 'cbi-value-field' }, [
]),
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, 'Private Key (PEM)'),
E('textarea', {
'id': 'import-key',
'class': 'cbi-input-textarea',
'rows': '6',
'rows': '4',
'placeholder': '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''),
E('div', { 'class': 'cbi-value-field' }, [
]),
E('div', { 'class': 'cert-form-row' }, [
E('label', {}, ''),
E('button', {
'class': 'cbi-button cbi-button-add',
'click': function() { self.handleImportCert(); }
}, 'Import Certificate')
}, '📥 Import Certificate')
])
])
]),
// Certificate list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Installed Certificates (' + certificates.length + ')'),
E('div', { 'class': 'haproxy-cert-list' },
E('div', { 'class': 'cert-section' }, [
E('h3', {}, '📋 Installed Certificates (' + certificates.length + ')'),
E('div', { 'class': 'cert-list' },
certificates.length === 0
? E('p', { 'style': 'color: var(--text-color-medium, #666)' }, 'No certificates installed.')
? E('p', { 'class': 'cert-empty' }, 'No certificates installed.')
: certificates.map(function(cert) {
return E('div', { 'class': 'haproxy-cert-item', 'data-id': cert.id }, [
E('div', {}, [
E('div', { 'class': 'haproxy-cert-domain' }, cert.domain),
E('div', { 'class': 'haproxy-cert-type' },
'Type: ' + (cert.type === 'acme' ? 'ACME (auto-renew)' : 'Manual'))
]),
E('div', {}, [
E('span', {
'class': 'haproxy-badge ' + (cert.enabled ? 'enabled' : 'disabled'),
'style': 'margin-right: 8px'
}, cert.enabled ? 'Enabled' : 'Disabled'),
E('button', {
'class': 'cbi-button cbi-button-remove',
'click': function() { self.handleDeleteCert(cert); }
}, 'Delete')
])
]);
return self.renderCertRow(cert);
})
)
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
.cbi-input-textarea {
width: 100%;
font-family: monospace;
}
`);
view.insertBefore(style, view.firstChild);
return view;
},
handleRequestCert: function() {
renderActiveTasks: function(tasks) {
var self = this;
return E('div', { 'class': 'cert-section cert-active-tasks' }, [
E('h3', {}, '⏳ Active Certificate Requests'),
E('div', { 'class': 'cert-task-list' },
tasks.map(function(task) {
return E('div', { 'class': 'cert-task-item' }, [
E('span', { 'class': 'cert-task-domain' }, task.domain),
E('span', { 'class': 'cert-task-phase' }, task.phase),
E('button', {
'class': 'cbi-button cbi-button-action',
'click': function() { self.resumeTaskPolling(task.task_id); }
}, '👁️ View Progress')
]);
})
)
]);
},
renderCertRow: function(cert) {
var self = this;
var isExpiringSoon = cert.expires_in && cert.expires_in < 30;
var typeIcon = cert.type === 'acme' ? '🔄' : '📄';
var statusIcon = cert.enabled ? '✅' : '⬜';
return E('div', { 'class': 'cert-row' }, [
E('span', { 'class': 'cert-col-status' }, statusIcon),
E('span', { 'class': 'cert-col-domain' }, [
E('strong', {}, cert.domain),
E('span', { 'class': 'cert-type-badge' }, typeIcon + ' ' + (cert.type === 'acme' ? 'ACME' : 'Manual'))
]),
E('span', { 'class': 'cert-col-expiry ' + (isExpiringSoon ? 'cert-expiring' : '') },
cert.expires ? '📅 ' + cert.expires : '-'
),
E('span', { 'class': 'cert-col-issuer' }, cert.issuer || '-'),
E('span', { 'class': 'cert-col-action' }, [
E('button', {
'class': 'cert-btn cert-btn-delete',
'title': 'Delete',
'click': function() { self.handleDeleteCert(cert); }
}, '🗑️')
])
]);
},
handleRequestCertAsync: function() {
var self = this;
var domain = document.getElementById('acme-domain').value.trim();
var staging = document.querySelector('input[name="acme-mode"]:checked').value === 'staging';
if (!domain) {
ui.addNotification(null, E('p', {}, 'Domain is required'), 'error');
ui.addNotification(null, E('p', {}, 'Domain is required'), 'error');
return;
}
ui.showModal('Requesting Certificate', [
E('p', { 'class': 'spinning' }, 'Requesting certificate for ' + domain + '...')
]);
// Validate domain format
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/.test(domain)) {
ui.addNotification(null, E('p', {}, '❌ Invalid domain format'), 'error');
return;
}
return api.requestCertificate(domain).then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, res.message || 'Certificate requested'));
window.location.reload();
// Show progress container
var progressContainer = document.getElementById('cert-progress-container');
progressContainer.style.display = 'block';
// Update UI
document.getElementById('cert-progress-domain').textContent = domain;
document.getElementById('cert-progress-status').textContent = staging ? '🧪 STAGING' : '🏭 PRODUCTION';
document.getElementById('cert-progress-status').className = 'cert-status ' + (staging ? 'cert-status-staging' : 'cert-status-prod');
document.getElementById('cert-progress-message').textContent = 'Starting certificate request...';
document.getElementById('btn-request-cert').disabled = true;
// Reset phase indicators
['starting', 'validating', 'requesting', 'verifying', 'complete'].forEach(function(phase) {
document.getElementById('phase-' + phase).className = 'cert-phase';
document.getElementById('phase-' + phase).textContent = '⬜ ' + document.getElementById('phase-' + phase).textContent.substring(2);
});
// Start async request
callStartCertRequest(domain, staging).then(function(res) {
if (res.success && res.task_id) {
self.currentTaskId = res.task_id;
self.startPolling();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
document.getElementById('cert-progress-icon').textContent = '❌';
document.getElementById('cert-progress-message').textContent = res.error || 'Failed to start request';
document.getElementById('btn-request-cert').disabled = false;
}
}).catch(function(err) {
document.getElementById('cert-progress-icon').textContent = '❌';
document.getElementById('cert-progress-message').textContent = 'Error: ' + err.message;
document.getElementById('btn-request-cert').disabled = false;
});
},
resumeTaskPolling: function(taskId) {
var self = this;
var progressContainer = document.getElementById('cert-progress-container');
progressContainer.style.display = 'block';
this.currentTaskId = taskId;
this.startPolling();
},
startPolling: function() {
var self = this;
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.pollInterval = setInterval(function() {
self.pollTaskStatus();
}, 2000);
// Poll immediately
this.pollTaskStatus();
},
stopPolling: function() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
},
pollTaskStatus: function() {
var self = this;
if (!this.currentTaskId) return;
callGetCertTask(this.currentTaskId).then(function(task) {
if (!task || task.error) {
self.stopPolling();
return;
}
// Update progress UI
self.updateProgressUI(task);
// Stop polling if complete or failed
if (task.status === 'success' || task.status === 'failed') {
self.stopPolling();
document.getElementById('btn-request-cert').disabled = false;
if (task.status === 'success') {
ui.addNotification(null, E('p', {}, '✅ Certificate issued for ' + task.domain), 'info');
setTimeout(function() { window.location.reload(); }, 2000);
}
}
}).catch(function() {
self.stopPolling();
});
},
updateProgressUI: function(task) {
var phaseIcons = {
'pending': '⏳', 'starting': '🔄', 'validating': '🔍',
'requesting': '📡', 'verifying': '✔️', 'complete': '✅'
};
var phases = ['starting', 'validating', 'requesting', 'verifying', 'complete'];
var currentPhaseIndex = phases.indexOf(task.phase);
// Update main icon
if (task.status === 'success') {
document.getElementById('cert-progress-icon').textContent = '✅';
} else if (task.status === 'failed') {
document.getElementById('cert-progress-icon').textContent = '❌';
} else {
document.getElementById('cert-progress-icon').textContent = phaseIcons[task.phase] || '⏳';
}
// Update phase indicators
phases.forEach(function(phase, index) {
var el = document.getElementById('phase-' + phase);
var label = el.textContent.substring(2);
if (index < currentPhaseIndex) {
el.className = 'cert-phase cert-phase-done';
el.textContent = '✅ ' + label;
} else if (index === currentPhaseIndex) {
el.className = 'cert-phase cert-phase-active';
el.textContent = (task.status === 'failed' ? '❌' : '🔄') + ' ' + label;
} else {
el.className = 'cert-phase';
el.textContent = '⬜ ' + label;
}
});
// Update message
document.getElementById('cert-progress-message').textContent = task.message || '';
// Update domain if needed
if (task.domain) {
document.getElementById('cert-progress-domain').textContent = task.domain;
}
},
handleImportCert: function() {
@ -162,46 +375,123 @@ return view.extend({
var key = document.getElementById('import-key').value.trim();
if (!domain || !cert || !key) {
ui.addNotification(null, E('p', {}, 'Domain, certificate and key are all required'), 'error');
ui.addNotification(null, E('p', {}, 'Domain, certificate and key are all required'), 'error');
return;
}
ui.showModal('📥 Importing Certificate', [
E('p', { 'class': 'spinning' }, 'Importing certificate for ' + domain + '...')
]);
return api.importCertificate(domain, cert, key).then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, res.message || 'Certificate imported'));
ui.addNotification(null, E('p', {}, '✅ ' + (res.message || 'Certificate imported')));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
},
handleDeleteCert: function(cert) {
ui.showModal('Delete Certificate', [
ui.showModal('🗑️ Delete Certificate', [
E('p', {}, 'Are you sure you want to delete the certificate for "' + cert.domain + '"?'),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Cancel'),
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
api.deleteCertificate(cert.id).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Certificate deleted'));
ui.addNotification(null, E('p', {}, 'Certificate deleted'));
window.location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error');
}
});
}
}, 'Delete')
}, '🗑️ Delete')
])
]);
},
getStyles: function() {
return `
.cert-section { margin-bottom: 25px; padding: 15px; background: #f8f9fa; border-radius: 8px; }
@media (prefers-color-scheme: dark) { .cert-section { background: #1a1a2e; } }
.cert-section h3 { margin: 0 0 15px 0; font-size: 1.1em; }
.cert-form-row { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.cert-form-row label { min-width: 120px; padding-top: 8px; font-weight: 500; }
.cert-form-row input[type="text"], .cert-form-row textarea { flex: 1; max-width: 400px; }
.cert-hint { font-size: 0.85em; color: #666; margin-left: 10px; padding-top: 8px; }
.cert-mode-toggle { display: flex; gap: 15px; }
.cert-mode-option { display: flex; flex-direction: column; padding: 10px 15px; border: 2px solid #ddd; border-radius: 8px; cursor: pointer; }
.cert-mode-option:has(input:checked) { border-color: #0099cc; background: rgba(0,153,204,0.1); }
.cert-mode-option input { display: none; }
.cert-mode-label { font-weight: 600; margin-bottom: 4px; }
.cert-mode-desc { font-size: 0.8em; color: #666; }
.cert-mode-prod { color: #22c55e; }
.cert-mode-staging { color: #f59e0b; }
.cert-progress { margin-top: 20px; padding: 15px; background: #fff; border: 2px solid #0099cc; border-radius: 8px; }
@media (prefers-color-scheme: dark) { .cert-progress { background: #16213e; } }
.cert-progress-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
.cert-progress-icon { font-size: 1.5em; }
.cert-progress-domain { font-weight: 600; font-size: 1.1em; flex: 1; }
.cert-status { padding: 4px 10px; border-radius: 12px; font-size: 0.8em; font-weight: 500; }
.cert-status-prod { background: #22c55e; color: #fff; }
.cert-status-staging { background: #f59e0b; color: #000; }
.cert-progress-phases { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.cert-phase { padding: 6px 12px; background: #eee; border-radius: 16px; font-size: 0.85em; }
.cert-phase-done { background: #dcfce7; color: #166534; }
.cert-phase-active { background: #dbeafe; color: #1d4ed8; }
@media (prefers-color-scheme: dark) {
.cert-phase { background: #333; }
.cert-phase-done { background: #166534; color: #dcfce7; }
.cert-phase-active { background: #1d4ed8; color: #dbeafe; }
}
.cert-progress-message { font-size: 0.9em; color: #666; padding: 8px; background: #f5f5f5; border-radius: 4px; }
@media (prefers-color-scheme: dark) { .cert-progress-message { background: #2a2a3e; color: #aaa; } }
.cert-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; }
@media (prefers-color-scheme: dark) { .cert-list { border-color: #444; } }
.cert-row { display: flex; align-items: center; padding: 12px; border-bottom: 1px solid #eee; gap: 15px; }
.cert-row:last-child { border-bottom: none; }
@media (prefers-color-scheme: dark) { .cert-row { border-bottom-color: #333; } }
.cert-col-status { width: 30px; text-align: center; font-size: 1.1em; }
.cert-col-domain { flex: 2; min-width: 150px; }
.cert-col-domain strong { display: block; }
.cert-type-badge { font-size: 0.8em; color: #666; }
.cert-col-expiry { flex: 1; min-width: 120px; font-size: 0.9em; }
.cert-expiring { color: #ef4444; font-weight: 500; }
.cert-col-issuer { flex: 1; min-width: 100px; font-size: 0.85em; color: #666; }
.cert-col-action { width: 50px; }
.cert-btn { border: none; background: transparent; cursor: pointer; font-size: 1.1em; padding: 6px 10px; border-radius: 4px; }
.cert-btn:hover { background: rgba(0,0,0,0.1); }
.cert-btn-delete:hover { background: rgba(239,68,68,0.2); }
.cert-empty { color: #888; font-style: italic; padding: 20px; text-align: center; }
.cert-active-tasks { background: #fef3c7; border: 2px solid #f59e0b; }
@media (prefers-color-scheme: dark) { .cert-active-tasks { background: #422006; border-color: #f59e0b; } }
.cert-task-list { display: flex; flex-direction: column; gap: 10px; }
.cert-task-item { display: flex; align-items: center; gap: 15px; padding: 10px; background: rgba(255,255,255,0.5); border-radius: 6px; }
.cert-task-domain { font-weight: 600; flex: 1; }
.cert-task-phase { font-size: 0.85em; color: #666; }
`;
},
handleSaveApply: null,
handleSave: null,
handleReset: null

View File

@ -765,7 +765,204 @@ _add_certificate() {
json_close_object
}
# Request certificate (ACME)
# Async certificate request task directory
CERT_TASK_DIR="/tmp/haproxy-cert-tasks"
mkdir -p "$CERT_TASK_DIR" 2>/dev/null
# Start async certificate request (returns immediately with task_id)
method_start_cert_request() {
local domain staging
read -r input
json_load "$input"
json_get_var domain domain
json_get_var staging staging
if [ -z "$domain" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Domain is required"
json_dump
return
fi
# Generate task ID
local task_id
task_id="cert_$(date +%s)_$$"
local task_file="$CERT_TASK_DIR/$task_id"
# Validate domain format
if ! echo "$domain" | grep -qE '^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$'; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Invalid domain format"
json_dump
return
fi
# Initialize task status
cat > "$task_file" <<EOF
{
"task_id": "$task_id",
"domain": "$domain",
"staging": ${staging:-0},
"status": "pending",
"phase": "starting",
"message": "Initializing certificate request...",
"started": $(date +%s),
"updated": $(date +%s)
}
EOF
# Start background process
(
# Update status: validating
sed -i 's/"phase": "[^"]*"/"phase": "validating"/' "$task_file"
sed -i 's/"message": "[^"]*"/"message": "Validating domain DNS..."/' "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
# Check DNS resolution
if ! nslookup "$domain" >/dev/null 2>&1; then
sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file"
sed -i 's/"message": "[^"]*"/"message": "DNS lookup failed for domain"/' "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
exit 1
fi
# Update status: requesting
sed -i 's/"phase": "[^"]*"/"phase": "requesting"/' "$task_file"
sed -i 's/"message": "[^"]*"/"message": "Requesting certificate from ACME..."/' "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
# Run certificate request
local acme_result acme_rc
if [ "${staging:-0}" = "1" ]; then
acme_result=$("$HAPROXYCTL" cert add "$domain" --staging 2>&1)
else
acme_result=$("$HAPROXYCTL" cert add "$domain" 2>&1)
fi
acme_rc=$?
# Update status based on result
if [ $acme_rc -eq 0 ]; then
# Verify certificate was created
sed -i 's/"phase": "[^"]*"/"phase": "verifying"/' "$task_file"
sed -i 's/"message": "[^"]*"/"message": "Verifying certificate..."/' "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
sleep 1
# Check if cert file exists
local cert_file="/srv/haproxy/certs/${domain}.pem"
if [ -f "$cert_file" ]; then
local expiry issuer
expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | sed 's/notAfter=//')
issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*O = //' | cut -d',' -f1)
sed -i 's/"status": "[^"]*"/"status": "success"/' "$task_file"
sed -i 's/"phase": "[^"]*"/"phase": "complete"/' "$task_file"
sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"Certificate issued by $issuer, expires $expiry\"/" "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
else
sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file"
sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"Certificate file not found after request\"/" "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
fi
else
sed -i 's/"status": "[^"]*"/"status": "failed"/' "$task_file"
# Escape special chars in error message
local safe_error
safe_error=$(echo "$acme_result" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-200)
sed -i "s/\"message\": \"[^\"]*\"/\"message\": \"$safe_error\"/" "$task_file"
sed -i "s/\"updated\": [0-9]*/\"updated\": $(date +%s)/" "$task_file"
fi
) &
# Return task ID immediately
json_init
json_add_boolean "success" 1
json_add_string "task_id" "$task_id"
json_add_string "message" "Certificate request started"
json_dump
}
# Get certificate task status
method_get_cert_task() {
local task_id
read -r input
json_load "$input"
json_get_var task_id task_id
if [ -z "$task_id" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "task_id is required"
json_dump
return
fi
local task_file="$CERT_TASK_DIR/$task_id"
if [ ! -f "$task_file" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Task not found"
json_dump
return
fi
# Return task file contents
cat "$task_file"
}
# List all certificate tasks
method_list_cert_tasks() {
json_init
json_add_array "tasks"
for task_file in "$CERT_TASK_DIR"/cert_*; do
[ -f "$task_file" ] || continue
local task_id status domain phase
task_id=$(basename "$task_file")
status=$(jsonfilter -i "$task_file" -e '@.status' 2>/dev/null)
domain=$(jsonfilter -i "$task_file" -e '@.domain' 2>/dev/null)
phase=$(jsonfilter -i "$task_file" -e '@.phase' 2>/dev/null)
json_add_object ""
json_add_string "task_id" "$task_id"
json_add_string "domain" "$domain"
json_add_string "status" "$status"
json_add_string "phase" "$phase"
json_close_object
done
json_close_array
json_dump
}
# Clean old certificate tasks (> 1 hour)
method_clean_cert_tasks() {
local cleaned=0
local now
now=$(date +%s)
for task_file in "$CERT_TASK_DIR"/cert_*; do
[ -f "$task_file" ] || continue
local started
started=$(jsonfilter -i "$task_file" -e '@.started' 2>/dev/null)
if [ -n "$started" ] && [ $((now - started)) -gt 3600 ]; then
rm -f "$task_file"
cleaned=$((cleaned + 1))
fi
done
json_init
json_add_boolean "success" 1
json_add_int "cleaned" "$cleaned"
json_dump
}
# Request certificate (ACME) - synchronous (kept for compatibility)
method_request_certificate() {
local domain
@ -1382,6 +1579,10 @@ case "$1" in
"delete_server": { "id": "string", "inline": "boolean" },
"list_certificates": {},
"request_certificate": { "domain": "string" },
"start_cert_request": { "domain": "string", "staging": "boolean" },
"get_cert_task": { "task_id": "string" },
"list_cert_tasks": {},
"clean_cert_tasks": {},
"import_certificate": { "domain": "string", "cert": "string", "key": "string" },
"delete_certificate": { "id": "string" },
"list_acls": {},
@ -1425,6 +1626,10 @@ EOF
delete_server) method_delete_server ;;
list_certificates) method_list_certificates ;;
request_certificate) method_request_certificate ;;
start_cert_request) method_start_cert_request ;;
get_cert_task) method_get_cert_task ;;
list_cert_tasks) method_list_cert_tasks ;;
clean_cert_tasks) method_clean_cert_tasks ;;
import_certificate) method_import_certificate ;;
delete_certificate) method_delete_certificate ;;
list_acls) method_list_acls ;;

View File

@ -12,6 +12,8 @@
"get_backend",
"list_servers",
"list_certificates",
"list_cert_tasks",
"get_cert_task",
"list_acls",
"list_redirects",
"get_settings",
@ -34,6 +36,10 @@
"update_server",
"delete_server",
"request_certificate",
"start_cert_request",
"get_cert_task",
"list_cert_tasks",
"clean_cert_tasks",
"import_certificate",
"delete_certificate",
"create_acl",

View File

@ -5,87 +5,19 @@
'require ui';
'require service-registry/api as api';
// Icon mapping
var icons = {
'server': '🖥️', 'music': '🎵', 'shield': '🛡️', 'chart': '📊',
'settings': '⚙️', 'git': '📦', 'blog': '📝', 'arrow': '➡️',
'onion': '🧅', 'lock': '🔒', 'globe': '🌐', 'box': '📦',
'app': '📱', 'admin': '👤', 'stats': '📈', 'security': '🔐',
'feed': '📡', 'default': '🔗'
// Category icons
var catIcons = {
'proxy': '🌐', 'privacy': '🧅', 'system': '⚙️', 'app': '📱',
'media': '🎵', 'security': '🔐', 'container': '📦', 'services': '🖥️',
'monitoring': '📊', 'other': '🔗'
};
function getIcon(name) {
return icons[name] || icons['default'];
// Generate QR code using QR Server API (free, reliable)
function generateQRCodeImg(data, size) {
var url = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(data);
return '<img src="' + url + '" alt="QR Code" style="display:block;" />';
}
// Simple QR code generator
var QRCode = {
generateSVG: function(data, size) {
// Basic implementation - generates a simple visual representation
var matrix = this.generateMatrix(data);
var cellSize = size / matrix.length;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
for (var row = 0; row < matrix.length; row++) {
for (var col = 0; col < matrix[row].length; col++) {
if (matrix[row][col]) {
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
}
}
}
svg += '</svg>';
return svg;
},
generateMatrix: function(data) {
var size = Math.max(21, Math.min(41, Math.ceil(data.length / 2) + 17));
var matrix = [];
for (var i = 0; i < size; i++) {
matrix[i] = [];
for (var j = 0; j < size; j++) {
matrix[i][j] = 0;
}
}
// Add finder patterns
this.addFinderPattern(matrix, 0, 0);
this.addFinderPattern(matrix, size - 7, 0);
this.addFinderPattern(matrix, 0, size - 7);
// Timing
for (var i = 8; i < size - 8; i++) {
matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0;
}
// Data encoding (simplified)
var dataIndex = 0;
for (var col = size - 1; col > 0; col -= 2) {
if (col === 6) col--;
for (var row = 0; row < size; row++) {
for (var c = 0; c < 2; c++) {
var x = col - c;
if (matrix[row][x] === 0 && dataIndex < data.length * 8) {
var byteIndex = Math.floor(dataIndex / 8);
var bitIndex = dataIndex % 8;
var bit = byteIndex < data.length ?
(data.charCodeAt(byteIndex) >> (7 - bitIndex)) & 1 : 0;
matrix[row][x] = bit;
dataIndex++;
}
}
}
}
return matrix;
},
addFinderPattern: function(matrix, row, col) {
for (var r = 0; r < 7; r++) {
for (var c = 0; c < 7; c++) {
if ((r === 0 || r === 6 || c === 0 || c === 6) ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
matrix[row + r][col + c] = 1;
}
}
}
}
};
return view.extend({
title: _('Service Registry'),
pollInterval: 30,
@ -98,232 +30,56 @@ return view.extend({
var self = this;
var services = data.services || [];
var providers = data.providers || {};
var categories = data.categories || [];
// Load CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('service-registry/registry.css');
document.head.appendChild(link);
var style = document.createElement('style');
style.textContent = this.getStyles();
document.head.appendChild(style);
return E('div', { 'class': 'sr-dashboard' }, [
this.renderHeader(),
this.renderStats(services, providers),
this.renderProviders(providers, data.haproxy, data.tor),
this.renderQuickPublish(categories),
this.renderServiceGrid(services, categories),
var published = services.filter(function(s) { return s.published; });
var unpublished = services.filter(function(s) { return !s.published; });
return E('div', { 'class': 'sr-compact' }, [
this.renderHeader(services, providers, data.haproxy, data.tor),
this.renderSection('📡 Published Services', published, true),
this.renderSection('🔍 Discovered Services', unpublished, false),
this.renderLandingLink(data.landing)
]);
},
renderHeader: function() {
return E('h2', { 'class': 'cbi-title' }, _('Service Registry'));
},
renderStats: function(services, providers) {
renderHeader: function(services, providers, haproxy, tor) {
var published = services.filter(function(s) { return s.published; }).length;
var running = services.filter(function(s) { return s.status === 'running'; }).length;
var haproxyCount = providers.haproxy ? providers.haproxy.count : 0;
var torCount = providers.tor ? providers.tor.count : 0;
return E('div', { 'class': 'sr-stats' }, [
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(published)),
E('div', { 'class': 'sr-stat-label' }, _('Published'))
var haproxyStatus = haproxy && haproxy.container_running ? '🟢' : '🔴';
var torStatus = tor && tor.running ? '🟢' : '🔴';
return E('div', { 'class': 'sr-header' }, [
E('div', { 'class': 'sr-title' }, [
E('h2', {}, '🗂️ Service Registry'),
E('span', { 'class': 'sr-subtitle' },
published + ' published · ' + running + ' running · ' +
haproxyCount + ' domains · ' + torCount + ' onion')
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(running)),
E('div', { 'class': 'sr-stat-label' }, _('Running'))
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(haproxyCount)),
E('div', { 'class': 'sr-stat-label' }, _('Domains'))
]),
E('div', { 'class': 'sr-stat-card' }, [
E('div', { 'class': 'sr-stat-value' }, String(torCount)),
E('div', { 'class': 'sr-stat-label' }, _('Onion Sites'))
E('div', { 'class': 'sr-providers-bar' }, [
E('span', { 'class': 'sr-provider-badge' }, haproxyStatus + ' HAProxy'),
E('span', { 'class': 'sr-provider-badge' }, torStatus + ' Tor'),
E('span', { 'class': 'sr-provider-badge' }, '📊 ' + (providers.direct ? providers.direct.count : 0) + ' ports'),
E('span', { 'class': 'sr-provider-badge' }, '📦 ' + (providers.lxc ? providers.lxc.count : 0) + ' LXC')
])
]);
},
renderProviders: function(providers, haproxy, tor) {
return E('div', { 'class': 'sr-providers' }, [
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot ' + (haproxy && haproxy.container_running ? 'running' : 'stopped') }),
E('span', {}, _('HAProxy'))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot ' + (tor && tor.running ? 'running' : 'stopped') }),
E('span', {}, _('Tor'))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot running' }),
E('span', {}, _('Direct: ') + String(providers.direct ? providers.direct.count : 0))
]),
E('div', { 'class': 'sr-provider' }, [
E('span', { 'class': 'sr-provider-dot running' }),
E('span', {}, _('LXC: ') + String(providers.lxc ? providers.lxc.count : 0))
])
]);
},
renderQuickPublish: function(categories) {
var self = this;
var categoryOptions = [E('option', { 'value': 'services' }, _('Services'))];
categories.forEach(function(cat) {
categoryOptions.push(E('option', { 'value': cat.id }, cat.name));
});
return E('div', { 'class': 'sr-quick-publish' }, [
E('h3', {}, _('Quick Publish')),
E('div', { 'class': 'sr-form' }, [
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Service Name')),
E('input', { 'type': 'text', 'id': 'pub-name', 'placeholder': 'e.g., Gitea' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Local Port')),
E('input', { 'type': 'number', 'id': 'pub-port', 'placeholder': '3000' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Domain (optional)')),
E('input', { 'type': 'text', 'id': 'pub-domain', 'placeholder': 'git.example.com' })
]),
E('div', { 'class': 'sr-form-group' }, [
E('label', {}, _('Category')),
E('select', { 'id': 'pub-category' }, categoryOptions)
]),
E('div', { 'class': 'sr-checkbox-group' }, [
E('input', { 'type': 'checkbox', 'id': 'pub-tor' }),
E('label', { 'for': 'pub-tor' }, _('Enable Tor Hidden Service'))
]),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handlePublish')
}, _('Publish'))
])
]);
},
handlePublish: function() {
var self = this;
var name = document.getElementById('pub-name').value.trim();
var port = parseInt(document.getElementById('pub-port').value);
var domain = document.getElementById('pub-domain').value.trim();
var category = document.getElementById('pub-category').value;
var tor = document.getElementById('pub-tor').checked;
if (!name || !port) {
ui.addNotification(null, E('p', _('Name and port are required')), 'error');
return;
}
ui.showModal(_('Publishing Service'), [
E('p', { 'class': 'spinning' }, _('Creating service endpoints...'))
]);
return api.publishService(name, port, domain, tor, category, '').then(function(result) {
ui.hideModal();
if (result.success) {
self.showPublishedModal(result);
// Refresh view
return self.load().then(function(data) {
var container = document.querySelector('.sr-dashboard');
if (container) {
dom.content(container, self.render(data).childNodes);
}
});
} else {
ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + err.message), 'error');
});
},
showPublishedModal: function(result) {
var urls = result.urls || {};
var content = [
E('div', { 'class': 'sr-published-modal' }, [
E('h3', {}, _('Service Published Successfully!')),
E('p', {}, result.name)
])
];
var urlsDiv = E('div', { 'class': 'sr-urls' });
if (urls.local) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Local')),
E('input', { 'readonly': true, 'value': urls.local })
]));
}
if (urls.clearnet) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Clearnet')),
E('input', { 'readonly': true, 'value': urls.clearnet }),
E('div', { 'class': 'sr-qr-code' }),
]));
var qrDiv = urlsDiv.querySelector('.sr-qr-code:last-child');
if (qrDiv) {
qrDiv.innerHTML = QRCode.generateSVG(urls.clearnet, 120);
}
}
if (urls.onion) {
urlsDiv.appendChild(E('div', { 'class': 'sr-url-box' }, [
E('label', {}, _('Onion')),
E('input', { 'readonly': true, 'value': urls.onion }),
E('div', { 'class': 'sr-qr-code' })
]));
var qrDiv = urlsDiv.querySelectorAll('.sr-qr-code');
if (qrDiv.length > 0) {
qrDiv[qrDiv.length - 1].innerHTML = QRCode.generateSVG(urls.onion, 120);
}
}
content[0].appendChild(urlsDiv);
// Share buttons
var shareUrl = urls.clearnet || urls.onion || urls.local;
if (shareUrl) {
content[0].appendChild(E('div', { 'class': 'sr-share-buttons' }, [
E('a', {
'href': 'https://twitter.com/intent/tweet?url=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on X'
}, 'X'),
E('a', {
'href': 'https://t.me/share/url?url=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on Telegram'
}, 'TG'),
E('a', {
'href': 'https://wa.me/?text=' + encodeURIComponent(shareUrl),
'target': '_blank',
'title': 'Share on WhatsApp'
}, 'WA')
]));
}
content.push(E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
]));
ui.showModal(_('Service Published'), content);
},
renderServiceGrid: function(services, categories) {
renderSection: function(title, services, isPublished) {
var self = this;
if (services.length === 0) {
return E('div', { 'class': 'sr-empty' }, [
E('h3', {}, _('No Services Found')),
E('p', {}, _('Use the quick publish form above to add your first service'))
return E('div', { 'class': 'sr-section' }, [
E('h3', { 'class': 'sr-section-title' }, title),
E('div', { 'class': 'sr-empty-msg' }, isPublished ?
'No published services yet' : 'No discovered services')
]);
}
@ -335,178 +91,296 @@ return view.extend({
grouped[cat].push(svc);
});
var sections = [];
var lists = [];
Object.keys(grouped).sort().forEach(function(cat) {
sections.push(E('div', { 'class': 'cbi-section' }, [
E('h3', {}, cat.charAt(0).toUpperCase() + cat.slice(1)),
E('div', { 'class': 'sr-grid' },
var catIcon = catIcons[cat] || '🔗';
lists.push(E('div', { 'class': 'sr-category' }, [
E('div', { 'class': 'sr-cat-header' }, catIcon + ' ' + cat.charAt(0).toUpperCase() + cat.slice(1)),
E('div', { 'class': 'sr-list' },
grouped[cat].map(function(svc) {
return self.renderServiceCard(svc);
return self.renderServiceRow(svc, isPublished);
})
)
]));
});
return E('div', {}, sections);
},
renderServiceCard: function(service) {
var self = this;
var urls = service.urls || {};
var urlRows = [];
if (urls.local) {
urlRows.push(this.renderUrlRow('Local', urls.local));
}
if (urls.clearnet) {
urlRows.push(this.renderUrlRow('Clearnet', urls.clearnet));
}
if (urls.onion) {
urlRows.push(this.renderUrlRow('Onion', urls.onion));
}
// QR codes for published services
var qrContainer = null;
if (service.published && (urls.clearnet || urls.onion)) {
var qrBoxes = [];
if (urls.clearnet) {
var qrBox = E('div', { 'class': 'sr-qr-box' }, [
E('div', { 'class': 'sr-qr-code' }),
E('div', { 'class': 'sr-qr-label' }, _('Clearnet'))
]);
qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.clearnet, 80);
qrBoxes.push(qrBox);
}
if (urls.onion) {
var qrBox = E('div', { 'class': 'sr-qr-box' }, [
E('div', { 'class': 'sr-qr-code' }),
E('div', { 'class': 'sr-qr-label' }, _('Onion'))
]);
qrBox.querySelector('.sr-qr-code').innerHTML = QRCode.generateSVG(urls.onion, 80);
qrBoxes.push(qrBox);
}
qrContainer = E('div', { 'class': 'sr-qr-container' }, qrBoxes);
}
// Action buttons
var actions = [];
if (service.published) {
actions.push(E('button', {
'class': 'cbi-button cbi-button-remove',
'click': ui.createHandlerFn(this, 'handleUnpublish', service.id)
}, _('Unpublish')));
} else {
actions.push(E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handleQuickPublishExisting', service)
}, _('Publish')));
}
return E('div', { 'class': 'sr-card' }, [
E('div', { 'class': 'sr-card-header' }, [
E('div', { 'class': 'sr-card-icon' }, getIcon(service.icon)),
E('div', { 'class': 'sr-card-title' }, service.name || service.id),
E('span', {
'class': 'sr-card-status sr-status-' + (service.status || 'stopped')
}, service.status || 'unknown')
]),
E('div', { 'class': 'sr-urls' }, urlRows),
qrContainer,
E('div', { 'class': 'sr-card-actions' }, actions)
return E('div', { 'class': 'sr-section' }, [
E('h3', { 'class': 'sr-section-title' }, title + ' (' + services.length + ')'),
E('div', { 'class': 'sr-categories' }, lists)
]);
},
renderUrlRow: function(label, url) {
return E('div', { 'class': 'sr-url-row' }, [
E('span', { 'class': 'sr-url-label' }, label),
E('a', {
'class': 'sr-url-link',
'href': url,
'target': '_blank'
}, url),
E('button', {
'class': 'cbi-button sr-copy-btn',
'click': function() {
navigator.clipboard.writeText(url).then(function() {
ui.addNotification(null, E('p', _('URL copied to clipboard')), 'info');
});
}
}, _('Copy'))
renderServiceRow: function(service, isPublished) {
var self = this;
var urls = service.urls || {};
// Status indicators
var healthIcon = service.status === 'running' ? '🟢' :
service.status === 'stopped' ? '🔴' : '🟡';
var publishIcon = service.published ? '✅' : '⬜';
// Build URL display
var urlDisplay = '';
if (urls.clearnet) {
urlDisplay = urls.clearnet;
} else if (urls.onion) {
urlDisplay = urls.onion.substring(0, 25) + '...';
} else if (urls.local) {
urlDisplay = urls.local;
}
// Port display
var portDisplay = service.local_port ? ':' + service.local_port : '';
if (service.haproxy && service.haproxy.backend_port) {
portDisplay = ':' + service.haproxy.backend_port;
}
// SSL/Cert badge
var sslBadge = null;
if (service.haproxy) {
if (service.haproxy.acme) {
sslBadge = E('span', { 'class': 'sr-badge sr-badge-acme', 'title': 'ACME Certificate' }, '🔒');
} else if (service.haproxy.ssl) {
sslBadge = E('span', { 'class': 'sr-badge sr-badge-ssl', 'title': 'SSL Enabled' }, '🔐');
}
}
// Tor badge
var torBadge = null;
if (service.tor && service.tor.enabled) {
torBadge = E('span', { 'class': 'sr-badge sr-badge-tor', 'title': 'Tor Hidden Service' }, '🧅');
}
// QR button for published services with URLs
var qrBtn = null;
if (service.published && (urls.clearnet || urls.onion)) {
qrBtn = E('button', {
'class': 'sr-btn sr-btn-qr',
'title': 'Show QR Code',
'click': ui.createHandlerFn(this, 'handleShowQR', service)
}, '📱');
}
// Action button
var actionBtn;
if (isPublished) {
actionBtn = E('button', {
'class': 'sr-btn sr-btn-unpublish',
'title': 'Unpublish',
'click': ui.createHandlerFn(this, 'handleUnpublish', service.id)
}, '✖');
} else {
actionBtn = E('button', {
'class': 'sr-btn sr-btn-publish',
'title': 'Quick Publish',
'click': ui.createHandlerFn(this, 'handleQuickPublish', service)
}, '📤');
}
return E('div', { 'class': 'sr-row' }, [
E('span', { 'class': 'sr-col-health', 'title': service.status || 'unknown' }, healthIcon),
E('span', { 'class': 'sr-col-publish' }, publishIcon),
E('span', { 'class': 'sr-col-name' }, [
E('strong', {}, service.name || service.id),
E('span', { 'class': 'sr-port' }, portDisplay)
]),
E('span', { 'class': 'sr-col-url' },
urlDisplay ? E('a', { 'href': urlDisplay.startsWith('http') ? urlDisplay : 'http://' + urlDisplay, 'target': '_blank' }, urlDisplay) : '-'
),
E('span', { 'class': 'sr-col-badges' }, [sslBadge, torBadge].filter(Boolean)),
E('span', { 'class': 'sr-col-qr' }, qrBtn),
E('span', { 'class': 'sr-col-action' }, actionBtn)
]);
},
handleShowQR: function(service) {
var urls = service.urls || {};
var qrBoxes = [];
if (urls.clearnet) {
var qrDiv = E('div', { 'class': 'sr-qr-box' });
qrDiv.innerHTML = '<div class="sr-qr-code">' + generateQRCodeImg(urls.clearnet, 150) + '</div>' +
'<div class="sr-qr-label">🌐 Clearnet</div>' +
'<div class="sr-qr-url">' + urls.clearnet + '</div>';
qrBoxes.push(qrDiv);
}
if (urls.onion) {
var qrDiv = E('div', { 'class': 'sr-qr-box' });
qrDiv.innerHTML = '<div class="sr-qr-code">' + generateQRCodeImg(urls.onion, 150) + '</div>' +
'<div class="sr-qr-label">🧅 Onion</div>' +
'<div class="sr-qr-url">' + urls.onion + '</div>';
qrBoxes.push(qrDiv);
}
ui.showModal('📱 ' + (service.name || service.id), [
E('div', { 'class': 'sr-qr-modal' }, qrBoxes),
E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close'))
])
]);
},
handleUnpublish: function(serviceId) {
var self = this;
if (!confirm('Unpublish this service?')) return;
ui.showModal(_('Unpublish Service'), [
E('p', {}, _('Are you sure you want to unpublish this service?')),
E('p', {}, _('This will remove HAProxy vhost and Tor hidden service if configured.')),
E('div', { 'class': 'right' }, [
ui.showModal(_('Unpublishing'), [
E('p', { 'class': 'spinning' }, _('Removing service...'))
]);
api.unpublishService(serviceId).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Service unpublished')), 'info');
location.reload();
} else {
ui.addNotification(null, E('p', _('Failed to unpublish')), 'error');
}
});
},
handleQuickPublish: function(service) {
var self = this;
var name = service.name || service.id;
var port = service.local_port || (service.haproxy ? service.haproxy.backend_port : 0);
ui.showModal(_('Quick Publish: ' + name), [
E('div', { 'class': 'sr-publish-form' }, [
E('div', { 'class': 'sr-form-row' }, [
E('label', {}, 'Domain (optional):'),
E('input', { 'type': 'text', 'id': 'qp-domain', 'placeholder': 'example.com' })
]),
E('div', { 'class': 'sr-form-row' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'qp-tor' }),
' Enable Tor Hidden Service'
])
])
]),
E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
E('button', {
'class': 'cbi-button cbi-button-negative',
'class': 'cbi-button cbi-button-apply',
'click': function() {
var domain = document.getElementById('qp-domain').value.trim();
var tor = document.getElementById('qp-tor').checked;
ui.hideModal();
ui.showModal(_('Unpublishing'), [
E('p', { 'class': 'spinning' }, _('Removing service...'))
ui.showModal(_('Publishing'), [
E('p', { 'class': 'spinning' }, _('Creating endpoints...'))
]);
api.unpublishService(serviceId).then(function(result) {
api.publishService(name, port, domain, tor, service.category || 'services', '').then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Service unpublished')), 'info');
return self.load().then(function(data) {
var container = document.querySelector('.sr-dashboard');
if (container) {
dom.content(container, self.render(data).childNodes);
}
});
ui.addNotification(null, E('p', '✅ ' + name + ' published!'), 'info');
location.reload();
} else {
ui.addNotification(null, E('p', _('Failed to unpublish')), 'error');
ui.addNotification(null, E('p', '❌ Failed: ' + (result.error || '')), 'error');
}
});
}
}, _('Unpublish'))
}, '📤 Publish')
])
]);
},
handleQuickPublishExisting: function(service) {
document.getElementById('pub-name').value = service.name || '';
document.getElementById('pub-port').value = service.local_port || '';
document.getElementById('pub-name').focus();
},
renderLandingLink: function(landing) {
var path = landing && landing.path ? landing.path : '/www/secubox-services.html';
var exists = landing && landing.exists;
return E('div', { 'class': 'sr-landing-link' }, [
E('span', {}, _('Landing Page:')),
return E('div', { 'class': 'sr-footer' }, [
E('span', {}, '📄 Landing Page: '),
exists ?
E('a', { 'href': '/secubox-services.html', 'target': '_blank' }, path) :
E('span', {}, _('Not generated')),
E('a', { 'href': '/secubox-services.html', 'target': '_blank' }, '/secubox-services.html ↗') :
E('span', { 'class': 'sr-muted' }, 'Not generated'),
E('button', {
'class': 'cbi-button',
'class': 'sr-btn sr-btn-regen',
'click': ui.createHandlerFn(this, 'handleRegenLanding')
}, _('Regenerate'))
}, '🔄 Regenerate')
]);
},
handleRegenLanding: function() {
var self = this;
ui.showModal(_('Generating'), [
E('p', { 'class': 'spinning' }, _('Regenerating landing page...'))
]);
api.generateLandingPage().then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Landing page regenerated')), 'info');
ui.addNotification(null, E('p', 'Landing page regenerated'), 'info');
} else {
ui.addNotification(null, E('p', _('Failed: ') + (result.error || '')), 'error');
ui.addNotification(null, E('p', 'Failed: ' + (result.error || '')), 'error');
}
});
},
getStyles: function() {
return `
.sr-compact { font-family: system-ui, -apple-system, sans-serif; }
.sr-header { margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 8px; color: #fff; }
.sr-title h2 { margin: 0 0 5px 0; font-size: 1.4em; }
.sr-subtitle { font-size: 0.85em; opacity: 0.8; }
.sr-providers-bar { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.sr-provider-badge { background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 12px; font-size: 0.8em; }
.sr-section { margin-bottom: 25px; }
.sr-section-title { font-size: 1.1em; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 2px solid #0ff; color: #0ff; }
.sr-empty-msg { color: #888; font-style: italic; padding: 15px; }
.sr-category { margin-bottom: 15px; }
.sr-cat-header { font-weight: 600; font-size: 0.9em; padding: 6px 10px; background: #f5f5f5; border-radius: 4px; margin-bottom: 5px; }
@media (prefers-color-scheme: dark) { .sr-cat-header { background: #2a2a3e; } }
.sr-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; }
@media (prefers-color-scheme: dark) { .sr-list { border-color: #444; } }
.sr-row { display: flex; align-items: center; padding: 8px 12px; border-bottom: 1px solid #eee; gap: 10px; transition: background 0.15s; }
.sr-row:last-child { border-bottom: none; }
.sr-row:hover { background: rgba(0,255,255,0.05); }
@media (prefers-color-scheme: dark) { .sr-row { border-bottom-color: #333; } }
.sr-col-health { width: 24px; text-align: center; font-size: 0.9em; }
.sr-col-publish { width: 24px; text-align: center; }
.sr-col-name { flex: 1; min-width: 120px; }
.sr-col-name strong { display: block; }
.sr-port { font-size: 0.8em; color: #888; }
.sr-col-url { flex: 2; min-width: 150px; font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sr-col-url a { color: #0099cc; text-decoration: none; }
.sr-col-url a:hover { text-decoration: underline; }
.sr-col-badges { width: 50px; display: flex; gap: 4px; }
.sr-col-qr { width: 36px; }
.sr-col-action { width: 36px; }
.sr-badge { font-size: 0.85em; }
.sr-btn { border: none; background: transparent; cursor: pointer; font-size: 1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; }
.sr-btn:hover { background: rgba(0,0,0,0.1); }
.sr-btn-publish { color: #22c55e; }
.sr-btn-publish:hover { background: rgba(34,197,94,0.15); }
.sr-btn-unpublish { color: #ef4444; }
.sr-btn-unpublish:hover { background: rgba(239,68,68,0.15); }
.sr-btn-qr { color: #0099cc; }
.sr-btn-qr:hover { background: rgba(0,153,204,0.15); }
.sr-btn-regen { margin-left: 10px; font-size: 0.85em; }
.sr-qr-modal { display: flex; gap: 30px; justify-content: center; flex-wrap: wrap; padding: 20px 0; }
.sr-qr-box { text-align: center; }
.sr-qr-code { background: #fff; padding: 10px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.sr-qr-code svg { display: block; }
.sr-qr-label { margin-top: 10px; font-weight: 600; font-size: 0.9em; }
.sr-qr-url { margin-top: 5px; font-size: 0.75em; color: #666; max-width: 180px; word-break: break-all; }
.sr-footer { margin-top: 20px; padding: 12px 15px; background: #f8f8f8; border-radius: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@media (prefers-color-scheme: dark) { .sr-footer { background: #1a1a2e; } }
.sr-muted { color: #888; }
.sr-publish-form { min-width: 300px; }
.sr-form-row { margin-bottom: 12px; }
.sr-form-row label { display: block; margin-bottom: 5px; }
.sr-form-row input[type="text"] { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
@media (max-width: 768px) {
.sr-row { flex-wrap: wrap; }
.sr-col-url { flex-basis: 100%; order: 10; margin-top: 5px; }
}
`;
}
});

View File

@ -5,6 +5,13 @@
'require form';
'require service-registry/api as api';
// Category icons
var catIcons = {
'proxy': '🌐', 'privacy': '🧅', 'system': '⚙️', 'app': '📱',
'media': '🎵', 'security': '🔐', 'container': '📦', 'services': '🖥️',
'monitoring': '📊', 'other': '🔗'
};
return view.extend({
title: _('Publish Service'),
@ -17,21 +24,20 @@ return view.extend({
render: function(data) {
var self = this;
var categories = data[0].categories || [];
var categories = (data[0] && data[0].categories) || [];
var unpublished = data[1] || [];
// Load CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('service-registry/registry.css');
document.head.appendChild(link);
// Inject styles
var style = document.createElement('style');
style.textContent = this.getStyles();
document.head.appendChild(style);
var m, s, o;
m = new form.Map('service-registry', _('Publish New Service'),
m = new form.Map('service-registry', '📤 ' + _('Publish New Service'),
_('Create a new published service with HAProxy reverse proxy and/or Tor hidden service.'));
s = m.section(form.NamedSection, '_new', 'service', _('Service Details'));
s = m.section(form.NamedSection, '_new', 'service', '📋 ' + _('Service Details'));
s.anonymous = true;
s.addremove = false;
@ -53,10 +59,11 @@ return view.extend({
o.rmempty = false;
o = s.option(form.ListValue, 'category', _('Category'));
o.value('services', _('Services'));
o.value('services', '🖥️ Services');
categories.forEach(function(cat) {
if (cat.id !== 'services') {
o.value(cat.id, cat.name);
var icon = catIcons[cat.id] || '🔗';
o.value(cat.id, icon + ' ' + cat.name);
}
});
o.default = 'services';
@ -67,7 +74,7 @@ return view.extend({
o.optional = true;
// HAProxy section
s = m.section(form.NamedSection, '_haproxy', 'haproxy', _('HAProxy (Clearnet)'),
s = m.section(form.NamedSection, '_haproxy', 'haproxy', '🌐 ' + _('HAProxy (Clearnet)'),
_('Configure a public domain with automatic HTTPS certificate'));
s.anonymous = true;
@ -85,7 +92,7 @@ return view.extend({
return true;
};
o = s.option(form.Flag, 'ssl', _('Enable SSL/TLS'),
o = s.option(form.Flag, 'ssl', _('🔒 Enable SSL/TLS'),
_('Request ACME certificate automatically'));
o.default = '1';
o.depends('enabled', '1');
@ -96,7 +103,7 @@ return view.extend({
o.depends('ssl', '1');
// Tor section
s = m.section(form.NamedSection, '_tor', 'tor', _('Tor Hidden Service'),
s = m.section(form.NamedSection, '_tor', 'tor', '🧅 ' + _('Tor Hidden Service'),
_('Create a .onion address for anonymous access'));
s.anonymous = true;
@ -113,20 +120,20 @@ return view.extend({
// Add custom publish button
var publishBtn = E('button', {
'class': 'cbi-button cbi-button-apply',
'style': 'margin-top: 20px;',
'style': 'margin-top: 20px; font-size: 1.1em; padding: 10px 25px;',
'click': ui.createHandlerFn(self, 'handlePublish', m)
}, _('Publish Service'));
}, '📤 ' + _('Publish Service'));
mapEl.appendChild(E('div', { 'class': 'cbi-page-actions' }, [publishBtn]));
// Add discoverable services section
if (unpublished.length > 0) {
mapEl.appendChild(E('div', { 'class': 'cbi-section', 'style': 'margin-top: 30px;' }, [
E('h3', {}, _('Discovered Services')),
E('p', {}, _('These services are running but not yet published:')),
E('div', { 'class': 'sr-grid' },
unpublished.slice(0, 10).map(function(svc) {
return self.renderDiscoveredCard(svc);
E('h3', { 'class': 'pub-section-title' }, '🔍 ' + _('Discovered Services') + ' (' + unpublished.length + ')'),
E('p', { 'class': 'pub-hint' }, _('Click a service to pre-fill the form above')),
E('div', { 'class': 'pub-list' },
unpublished.slice(0, 15).map(function(svc) {
return self.renderDiscoveredRow(svc);
})
)
]));
@ -136,32 +143,75 @@ return view.extend({
});
},
renderDiscoveredCard: function(service) {
renderDiscoveredRow: function(service) {
var self = this;
var catIcon = catIcons[service.category] || '🔗';
var statusIcon = service.status === 'running' ? '🟢' : service.status === 'stopped' ? '🔴' : '🟡';
return E('div', {
'class': 'sr-card',
'style': 'cursor: pointer;',
'class': 'pub-row',
'click': function() {
self.prefillForm(service);
}
}, [
E('div', { 'class': 'sr-card-header' }, [
E('div', { 'class': 'sr-card-title' }, service.name || 'Port ' + service.local_port),
E('span', { 'class': 'sr-card-status sr-status-running' }, 'running')
E('span', { 'class': 'pub-col-status', 'title': service.status }, statusIcon),
E('span', { 'class': 'pub-col-icon' }, catIcon),
E('span', { 'class': 'pub-col-name' }, [
E('strong', {}, service.name || 'Port ' + service.local_port),
service.local_port ? E('span', { 'class': 'pub-port' }, ':' + service.local_port) : null
]),
E('p', { 'style': 'font-size: 0.9em; color: #666;' },
_('Port: ') + service.local_port + ' | ' + _('Category: ') + (service.category || 'other'))
E('span', { 'class': 'pub-col-cat' }, service.category || 'other'),
E('span', { 'class': 'pub-col-action' }, [
E('button', {
'class': 'pub-btn',
'title': 'Use this service',
'click': function(ev) {
ev.stopPropagation();
self.prefillForm(service);
}
}, '➡️')
])
]);
},
prefillForm: function(service) {
var nameInput = document.querySelector('input[id*="name"]');
var portInput = document.querySelector('input[id*="local_port"]');
// Use specific selectors matching the form section
var nameInput = document.querySelector('input[id*="_new"][id*="name"]');
var portInput = document.querySelector('input[id*="_new"][id*="local_port"]');
var categorySelect = document.querySelector('select[id*="_new"][id*="category"]');
var iconInput = document.querySelector('input[id*="_new"][id*="icon"]');
if (nameInput) nameInput.value = service.name || '';
if (portInput) portInput.value = service.local_port || '';
// Set values and trigger change events for LuCI bindings
if (nameInput) {
nameInput.value = service.name || '';
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
nameInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (portInput) {
portInput.value = service.local_port || '';
portInput.dispatchEvent(new Event('input', { bubbles: true }));
portInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (categorySelect && service.category) {
for (var i = 0; i < categorySelect.options.length; i++) {
if (categorySelect.options[i].value === service.category) {
categorySelect.selectedIndex = i;
categorySelect.dispatchEvent(new Event('change', { bubbles: true }));
break;
}
}
}
if (iconInput && service.icon) {
iconInput.value = service.icon;
iconInput.dispatchEvent(new Event('input', { bubbles: true }));
}
nameInput && nameInput.focus();
// Scroll to form and focus
var formSection = document.querySelector('.cbi-section');
if (formSection) formSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(function() { nameInput && nameInput.focus(); }, 300);
ui.addNotification(null, E('p', '✅ ' + _('Form pre-filled with ') + (service.name || 'Port ' + service.local_port)), 'info');
},
handlePublish: function(map) {
@ -186,25 +236,26 @@ return view.extend({
// Validation
if (!name) {
ui.addNotification(null, E('p', _('Service name is required')), 'error');
ui.addNotification(null, E('p', '❌ ' + _('Service name is required')), 'error');
return;
}
if (!port || port < 1 || port > 65535) {
ui.addNotification(null, E('p', _('Valid port number is required')), 'error');
ui.addNotification(null, E('p', '❌ ' + _('Valid port number is required')), 'error');
return;
}
if (haproxyEnabled && !domain) {
ui.addNotification(null, E('p', _('Domain is required when HAProxy is enabled')), 'error');
ui.addNotification(null, E('p', '❌ ' + _('Domain is required when HAProxy is enabled')), 'error');
return;
}
ui.showModal(_('Publishing Service'), [
var steps = [];
if (haproxyEnabled) steps.push('🌐 Creating HAProxy vhost for ' + domain);
if (haproxyEnabled) steps.push('🔒 Requesting SSL certificate...');
if (torEnabled) steps.push('🧅 Creating Tor hidden service...');
ui.showModal('📤 ' + _('Publishing Service'), [
E('p', { 'class': 'spinning' }, _('Creating service endpoints...')),
E('ul', {}, [
haproxyEnabled ? E('li', {}, _('Creating HAProxy vhost for ') + domain) : null,
haproxyEnabled ? E('li', {}, _('Requesting SSL certificate...')) : null,
torEnabled ? E('li', {}, _('Creating Tor hidden service...')) : null
].filter(Boolean))
E('ul', { 'style': 'margin-top: 15px;' }, steps.map(function(s) { return E('li', {}, s); }))
]);
return api.publishService(
@ -220,11 +271,11 @@ return view.extend({
if (result.success) {
self.showSuccessModal(result);
} else {
ui.addNotification(null, E('p', _('Failed to publish: ') + (result.error || 'Unknown error')), 'error');
ui.addNotification(null, E('p', '❌ ' + _('Failed to publish: ') + (result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: ') + err.message), 'error');
ui.addNotification(null, E('p', '❌ ' + _('Error: ') + err.message), 'error');
});
},
@ -233,51 +284,94 @@ return view.extend({
var content = [
E('div', { 'style': 'text-align: center; padding: 20px;' }, [
E('h3', { 'style': 'color: #22c55e;' }, _('Service Published!')),
E('p', {}, result.name)
E('div', { 'style': 'font-size: 3em; margin-bottom: 10px;' }, '✅'),
E('h3', { 'style': 'color: #22c55e; margin: 0;' }, _('Service Published!')),
E('p', { 'style': 'font-size: 1.2em; margin-top: 10px;' }, result.name)
])
];
var urlsDiv = E('div', { 'style': 'margin: 20px 0;' });
var urlsDiv = E('div', { 'class': 'pub-urls' });
if (urls.local) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Local: ')),
urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [
E('span', { 'class': 'pub-url-icon' }, '🏠'),
E('span', { 'class': 'pub-url-label' }, _('Local')),
E('code', {}, urls.local)
]));
}
if (urls.clearnet) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Clearnet: ')),
E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet)
urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [
E('span', { 'class': 'pub-url-icon' }, '🌐'),
E('span', { 'class': 'pub-url-label' }, _('Clearnet')),
E('a', { 'href': urls.clearnet, 'target': '_blank' }, urls.clearnet + ' ↗')
]));
}
if (urls.onion) {
urlsDiv.appendChild(E('div', { 'style': 'margin: 10px 0;' }, [
E('strong', {}, _('Onion: ')),
E('code', { 'style': 'word-break: break-all;' }, urls.onion)
urlsDiv.appendChild(E('div', { 'class': 'pub-url-row' }, [
E('span', { 'class': 'pub-url-icon' }, '🧅'),
E('span', { 'class': 'pub-url-label' }, _('Onion')),
E('code', { 'style': 'font-size: 0.8em; word-break: break-all;' }, urls.onion)
]));
}
content.push(urlsDiv);
content.push(E('div', { 'class': 'right' }, [
content.push(E('div', { 'class': 'right', 'style': 'margin-top: 20px;' }, [
E('button', {
'class': 'cbi-button',
'click': function() {
ui.hideModal();
window.location.href = L.url('admin/services/service-registry/overview');
}
}, _('Go to Overview')),
}, '📋 ' + _('Go to Overview')),
E('button', {
'class': 'cbi-button cbi-button-apply',
'style': 'margin-left: 10px;',
'click': function() {
ui.hideModal();
window.location.reload();
}
}, _('Publish Another'))
}, ' ' + _('Publish Another'))
]));
ui.showModal(_('Success'), content);
ui.showModal('✅ ' + _('Success'), content);
},
getStyles: function() {
return `
.pub-section-title { font-size: 1.1em; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 2px solid #0ff; color: #0ff; }
.pub-hint { color: #888; font-size: 0.9em; margin-bottom: 15px; }
.pub-list { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; max-height: 400px; overflow-y: auto; }
@media (prefers-color-scheme: dark) { .pub-list { border-color: #444; } }
.pub-row { display: flex; align-items: center; padding: 10px 12px; border-bottom: 1px solid #eee; gap: 10px; cursor: pointer; transition: background 0.15s; }
.pub-row:last-child { border-bottom: none; }
.pub-row:hover { background: rgba(0,255,255,0.08); }
@media (prefers-color-scheme: dark) { .pub-row { border-bottom-color: #333; } }
.pub-col-status { width: 24px; text-align: center; }
.pub-col-icon { width: 24px; text-align: center; }
.pub-col-name { flex: 1; min-width: 120px; }
.pub-col-name strong { display: inline; }
.pub-port { font-size: 0.85em; color: #888; margin-left: 4px; }
.pub-col-cat { width: 80px; font-size: 0.85em; color: #666; }
.pub-col-action { width: 36px; }
.pub-btn { border: none; background: transparent; cursor: pointer; font-size: 1.1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; }
.pub-btn:hover { background: rgba(0,153,204,0.15); }
.pub-urls { margin: 20px 0; padding: 15px; background: #f8f8f8; border-radius: 8px; }
@media (prefers-color-scheme: dark) { .pub-urls { background: #1a1a2e; } }
.pub-url-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #eee; }
.pub-url-row:last-child { border-bottom: none; }
@media (prefers-color-scheme: dark) { .pub-url-row { border-bottom-color: #333; } }
.pub-url-icon { font-size: 1.2em; }
.pub-url-label { font-weight: 600; min-width: 70px; }
.pub-url-row a { color: #0099cc; text-decoration: none; }
.pub-url-row a:hover { text-decoration: underline; }
`;
}
});

View File

@ -203,8 +203,9 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD'
border-radius: 8px;
display: inline-block;
}
.qr-code svg {
.qr-code img {
display: block;
border-radius: 4px;
}
.qr-label {
font-size: 0.7em;
@ -264,77 +265,11 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD'
</div>
<script>
// QR Code generator (minimal SVG implementation)
var QRCode = (function() {
function generateMatrix(data) {
// Simple QR matrix generator - uses a basic encoding
// For production, use a proper library
var size = Math.max(21, Math.ceil(data.length / 2) + 17);
size = Math.min(size, 41);
var matrix = [];
for (var i = 0; i < size; i++) {
matrix[i] = [];
for (var j = 0; j < size; j++) {
matrix[i][j] = 0;
}
}
// Add finder patterns
addFinderPattern(matrix, 0, 0);
addFinderPattern(matrix, size - 7, 0);
addFinderPattern(matrix, 0, size - 7);
// Add timing patterns
for (var i = 8; i < size - 8; i++) {
matrix[6][i] = matrix[i][6] = i % 2 === 0 ? 1 : 0;
}
// Encode data (simplified)
var dataIndex = 0;
for (var col = size - 1; col > 0; col -= 2) {
if (col === 6) col--;
for (var row = 0; row < size; row++) {
for (var c = 0; c < 2; c++) {
var x = col - c;
if (matrix[row][x] === 0 && dataIndex < data.length * 8) {
var byteIndex = Math.floor(dataIndex / 8);
var bitIndex = dataIndex % 8;
var bit = byteIndex < data.length ?
(data.charCodeAt(byteIndex) >> (7 - bitIndex)) & 1 : 0;
matrix[row][x] = bit;
dataIndex++;
}
}
}
}
return matrix;
}
function addFinderPattern(matrix, row, col) {
for (var r = 0; r < 7; r++) {
for (var c = 0; c < 7; c++) {
if ((r === 0 || r === 6 || c === 0 || c === 6) ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
matrix[row + r][col + c] = 1;
}
}
}
}
return {
generateSVG: function(data, size) {
var matrix = generateMatrix(data);
var cellSize = size / matrix.length;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
for (var row = 0; row < matrix.length; row++) {
for (var col = 0; col < matrix[row].length; col++) {
if (matrix[row][col]) {
svg += '<rect x="' + (col * cellSize) + '" y="' + (row * cellSize) +
'" width="' + cellSize + '" height="' + cellSize + '" fill="black"/>';
}
}
}
svg += '</svg>';
return svg;
}
};
})();
// QR Code generator using QR Server API (free, reliable)
function generateQRCodeImg(data, size) {
var url = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(data);
return '<img src="' + url + '" alt="QR Code" width="' + size + '" height="' + size + '" style="display:block;" />';
}
// Service data
var servicesData = SERVICES_JSON_PLACEHOLDER;
@ -445,7 +380,7 @@ cat > "$OUTPUT_PATH" <<'HTMLHEAD'
html += '<div class="qr-container">';
qrUrls.forEach(function(qr) {
html += '<div class="qr-box">';
html += '<div class="qr-code">' + QRCode.generateSVG(qr.url, 100) + '</div>';
html += '<div class="qr-code">' + generateQRCodeImg(qr.url, 120) + '</div>';
html += '<div class="qr-label">' + qr.label + '</div>';
html += '</div>';
});