@@ -3199,6 +3199,7 @@ sub app_html {
|
||
| 3199 | 3199 |
<th style="width: 190px">Host</th> |
| 3200 | 3200 |
<th style="width: 140px">IP</th> |
| 3201 | 3201 |
<th style="width: 180px">Derived aliases</th> |
| 3202 |
+ <th style="width: 260px">Certificate</th> |
|
| 3202 | 3203 |
<th style="width: 120px">Monitoring</th> |
| 3203 | 3204 |
<th style="width: 90px">Status</th> |
| 3204 | 3205 |
<th style="width: 90px">Actions</th> |
@@ -3318,7 +3319,7 @@ sub app_html {
|
||
| 3318 | 3319 |
</div> |
| 3319 | 3320 |
|
| 3320 | 3321 |
<script> |
| 3321 |
- let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
|
|
| 3322 |
+ let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
|
|
| 3322 | 3323 |
let hostFormSnapshot = ''; |
| 3323 | 3324 |
let hostFormBusy = false; |
| 3324 | 3325 |
let hostFormMode = 'new'; |
@@ -3475,6 +3476,8 @@ sub app_html {
|
||
| 3475 | 3476 |
showApp(); |
| 3476 | 3477 |
const data = await api('/api/hosts');
|
| 3477 | 3478 |
state.hosts = data.hosts || []; |
| 3479 |
+ state.vhosts = data.vhosts || []; |
|
| 3480 |
+ state.certificates = data.certificates || []; |
|
| 3478 | 3481 |
state.problems = data.problems || []; |
| 3479 | 3482 |
render(data); |
| 3480 | 3483 |
await renderCa(); |
@@ -3510,6 +3513,12 @@ sub app_html {
|
||
| 3510 | 3513 |
return; |
| 3511 | 3514 |
} |
| 3512 | 3515 |
const certs = await api('/api/ca/certificates');
|
| 3516 |
+ state.certificates = certs.map(cert => ({
|
|
| 3517 |
+ ...cert, |
|
| 3518 |
+ id: cert.id || cert.name || '', |
|
| 3519 |
+ name: cert.name || cert.id || '', |
|
| 3520 |
+ has_private_key: !!cert.has_private_key |
|
| 3521 |
+ })); |
|
| 3513 | 3522 |
const caDays = daysUntil(status.not_after); |
| 3514 | 3523 |
$('ca-status').innerHTML = `
|
| 3515 | 3524 |
<div class="muted ca-detail"> |
@@ -3833,6 +3842,7 @@ sub app_html {
|
||
| 3833 | 3842 |
} |
| 3834 | 3843 |
|
| 3835 | 3844 |
function vhostRows() {
|
| 3845 |
+ if (state.vhosts && state.vhosts.length) return state.vhosts; |
|
| 3836 | 3846 |
return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
|
| 3837 | 3847 |
vhost, |
| 3838 | 3848 |
host_id: host.id || '', |
@@ -3841,6 +3851,8 @@ sub app_html {
|
||
| 3841 | 3851 |
derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [], |
| 3842 | 3852 |
monitoring: host.monitoring || '', |
| 3843 | 3853 |
status: host.status || '', |
| 3854 |
+ certificate_id: '', |
|
| 3855 |
+ certificate: null, |
|
| 3844 | 3856 |
}))); |
| 3845 | 3857 |
} |
| 3846 | 3858 |
|
@@ -3865,10 +3877,11 @@ sub app_html {
|
||
| 3865 | 3877 |
</td> |
| 3866 | 3878 |
<td>${escapeHtml(row.ip)}</td>
|
| 3867 | 3879 |
<td><div class="vhost-pill-row">${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</div></td>
|
| 3880 |
+ <td>${renderVhostCertificateCell(row)}</td>
|
|
| 3868 | 3881 |
<td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
|
| 3869 | 3882 |
<td>${escapeHtml(row.status)}</td>
|
| 3870 | 3883 |
<td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
|
| 3871 |
- </tr>`).join('') : '<tr><td colspan="7" class="muted">No vhosts.</td></tr>';
|
|
| 3884 |
+ </tr>`).join('') : '<tr><td colspan="8" class="muted">No vhosts.</td></tr>';
|
|
| 3872 | 3885 |
document.querySelectorAll('[data-vhost-select]').forEach(select => {
|
| 3873 | 3886 |
select.addEventListener('change', () => {
|
| 3874 | 3887 |
reassignVhostFromSelect(select).catch(e => {
|
@@ -3884,6 +3897,40 @@ sub app_html {
|
||
| 3884 | 3897 |
}); |
| 3885 | 3898 |
}); |
| 3886 | 3899 |
}); |
| 3900 |
+ document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
|
|
| 3901 |
+ select.addEventListener('change', () => {
|
|
| 3902 |
+ setVhostCertificateFromSelect(select).catch(e => {
|
|
| 3903 |
+ if (!isAuthLost(e)) msg(e.message); |
|
| 3904 |
+ select.value = select.dataset.currentCertificate || ''; |
|
| 3905 |
+ }); |
|
| 3906 |
+ }); |
|
| 3907 |
+ }); |
|
| 3908 |
+ document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
|
|
| 3909 |
+ button.addEventListener('click', () => {
|
|
| 3910 |
+ issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '').catch(e => {
|
|
| 3911 |
+ if (!isAuthLost(e)) msg(e.message); |
|
| 3912 |
+ }); |
|
| 3913 |
+ }); |
|
| 3914 |
+ }); |
|
| 3915 |
+ } |
|
| 3916 |
+ |
|
| 3917 |
+ function renderVhostCertificateCell(row) {
|
|
| 3918 |
+ const cert = row.certificate || {};
|
|
| 3919 |
+ const certId = row.certificate_id || cert.id || cert.name || ''; |
|
| 3920 |
+ const links = certId ? `<div class="vhost-cert-links"> |
|
| 3921 |
+ <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
|
|
| 3922 |
+ ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
|
|
| 3923 |
+ </div>` : ''; |
|
| 3924 |
+ const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
|
|
| 3925 |
+ return `<div class="vhost-cert"> |
|
| 3926 |
+ <div class="vhost-cert-main"> |
|
| 3927 |
+ <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
|
|
| 3928 |
+ ${renderCertificateOptions(certId)}
|
|
| 3929 |
+ </select> |
|
| 3930 |
+ <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
|
|
| 3931 |
+ </div> |
|
| 3932 |
+ <div class="vhost-cert-meta">${links}${validity}</div>
|
|
| 3933 |
+ </div>`; |
|
| 3887 | 3934 |
} |
| 3888 | 3935 |
|
| 3889 | 3936 |
function renderVhostEditor() {
|
@@ -3904,6 +3951,19 @@ sub app_html {
|
||
| 3904 | 3951 |
}).join('');
|
| 3905 | 3952 |
} |
| 3906 | 3953 |
|
| 3954 |
+ function renderCertificateOptions(selectedCertificateId) {
|
|
| 3955 |
+ const certs = (state.certificates || []) |
|
| 3956 |
+ .slice() |
|
| 3957 |
+ .sort((a, b) => String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''))); |
|
| 3958 |
+ const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
|
|
| 3959 |
+ const id = cert.id || cert.name || ''; |
|
| 3960 |
+ const label = cert.name || cert.id || ''; |
|
| 3961 |
+ const selected = id === selectedCertificateId ? ' selected' : ''; |
|
| 3962 |
+ return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
|
|
| 3963 |
+ })); |
|
| 3964 |
+ return options.join('');
|
|
| 3965 |
+ } |
|
| 3966 |
+ |
|
| 3907 | 3967 |
function shortAliasForFqdn(name) {
|
| 3908 | 3968 |
const suffix = '.madagascar.xdev.ro'; |
| 3909 | 3969 |
name = String(name || '').toLowerCase(); |
@@ -3955,6 +4015,52 @@ sub app_html {
|
||
| 3955 | 4015 |
} |
| 3956 | 4016 |
} |
| 3957 | 4017 |
|
| 4018 |
+ async function setVhostCertificateFromSelect(select) {
|
|
| 4019 |
+ if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
|
|
| 4020 |
+ select.value = select.dataset.currentCertificate || ''; |
|
| 4021 |
+ return; |
|
| 4022 |
+ } |
|
| 4023 |
+ const vhost = select.dataset.vhostCertSelect || ''; |
|
| 4024 |
+ const certificateId = select.value || ''; |
|
| 4025 |
+ const current = select.dataset.currentCertificate || ''; |
|
| 4026 |
+ if (!vhost || certificateId === current) return; |
|
| 4027 |
+ if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
|
|
| 4028 |
+ select.value = current; |
|
| 4029 |
+ return; |
|
| 4030 |
+ } |
|
| 4031 |
+ select.disabled = true; |
|
| 4032 |
+ try {
|
|
| 4033 |
+ await api('/api/vhosts/certificate', {
|
|
| 4034 |
+ method: 'POST', |
|
| 4035 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 4036 |
+ body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
|
|
| 4037 |
+ }); |
|
| 4038 |
+ msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
|
|
| 4039 |
+ await refresh(); |
|
| 4040 |
+ } finally {
|
|
| 4041 |
+ select.disabled = false; |
|
| 4042 |
+ } |
|
| 4043 |
+ } |
|
| 4044 |
+ |
|
| 4045 |
+ async function issueVhostCertificate(vhost, currentCertificateId) {
|
|
| 4046 |
+ if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
|
|
| 4047 |
+ if (!vhost) return; |
|
| 4048 |
+ if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
|
|
| 4049 |
+ const button = document.querySelector(`[data-vhost-cert-issue="${CSS.escape(vhost)}"]`);
|
|
| 4050 |
+ if (button) button.disabled = true; |
|
| 4051 |
+ try {
|
|
| 4052 |
+ const result = await api('/api/vhosts/issue-certificate', {
|
|
| 4053 |
+ method: 'POST', |
|
| 4054 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 4055 |
+ body: JSON.stringify({ vhost_fqdn: vhost }),
|
|
| 4056 |
+ }); |
|
| 4057 |
+ msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
|
|
| 4058 |
+ await refresh(); |
|
| 4059 |
+ } finally {
|
|
| 4060 |
+ if (button) button.disabled = false; |
|
| 4061 |
+ } |
|
| 4062 |
+ } |
|
| 4063 |
+ |
|
| 3958 | 4064 |
async function deleteVhostInline(vhost) {
|
| 3959 | 4065 |
if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
|
| 3960 | 4066 |
if (!vhost || !confirm(`Delete ${vhost}?`)) return;
|