Showing 1 changed files with 108 additions and 2 deletions
+108 -2
scripts/host_manager.pl
@@ -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;