Showing 1 changed files with 357 additions and 24 deletions
+357 -24
scripts/host_manager.pl
@@ -206,6 +206,14 @@ sub handle_client {
206 206
             my $payload = request_payload(\%headers, $body);
207 207
             return delete_host($client, $payload->{id} || '');
208 208
         }
209
+        if ($path eq '/api/hosts/certificate') {
210
+            my $payload = request_payload(\%headers, $body);
211
+            return set_host_certificate($client, $payload);
212
+        }
213
+        if ($path eq '/api/hosts/issue-certificate') {
214
+            my $payload = request_payload(\%headers, $body);
215
+            return issue_host_certificate($client, $payload);
216
+        }
209 217
         if ($path eq '/api/vhosts/reassign') {
210 218
             my $payload = request_payload(\%headers, $body);
211 219
             return reassign_vhost($client, $payload);
@@ -410,8 +418,9 @@ sub apply_work_order {
410 418
 sub registry_payload {
411 419
     my ($registry) = @_;
412 420
     my $problems = analyze_hosts($registry->{hosts});
413
-    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
414 421
     my $dbh = dbh();
422
+    my %host_tls = host_tls_payloads($dbh);
423
+    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }) } @{ $registry->{hosts} };
415 424
     my @vhosts = vhost_payloads($dbh);
416 425
     my @certificates = certificate_payloads($dbh);
417 426
     my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
@@ -431,6 +440,45 @@ sub registry_payload {
431 440
     };
432 441
 }
433 442
 
443
+sub host_tls_payloads {
444
+    my ($dbh) = @_;
445
+    my %rows;
446
+    my $sth = $dbh->prepare(<<'SQL');
447
+SELECT
448
+    ht.host_fqdn,
449
+    ht.certificate_id,
450
+    c.common_name,
451
+    c.not_after,
452
+    c.fingerprint_sha256,
453
+    c.status AS certificate_status
454
+FROM host_tls ht
455
+LEFT JOIN certificates c ON c.certificate_id = ht.certificate_id
456
+ORDER BY ht.host_fqdn
457
+SQL
458
+    $sth->execute;
459
+    while (my $row = $sth->fetchrow_hashref) {
460
+        my $host_fqdn = clean_scalar($row->{host_fqdn} || '');
461
+        next unless length $host_fqdn;
462
+        my $cert_id = clean_scalar($row->{certificate_id} || '');
463
+        my %payload = (
464
+            certificate_id => $cert_id,
465
+        );
466
+        if (length $cert_id) {
467
+            $payload{certificate} = {
468
+                id => $cert_id,
469
+                name => $cert_id,
470
+                common_name => clean_scalar($row->{common_name} || ''),
471
+                status => clean_scalar($row->{certificate_status} || ''),
472
+                not_after => clean_scalar($row->{not_after} || ''),
473
+                fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
474
+                has_private_key => json_bool(ca_private_key_exists($cert_id)),
475
+            };
476
+        }
477
+        $rows{$host_fqdn} = \%payload;
478
+    }
479
+    return %rows;
480
+}
481
+
434 482
 sub vhost_payloads {
435 483
     my ($dbh) = @_;
436 484
     my @rows;
@@ -705,6 +753,70 @@ sub delete_vhost {
705 753
     return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
706 754
 }
707 755
 
756
+sub set_host_certificate {
757
+    my ($client, $payload) = @_;
758
+    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
759
+    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
760
+    my $certificate_id = clean_certificate_id($raw_certificate_id);
761
+    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
762
+    return send_json($client, 400, { error => 'invalid_certificate' })
763
+        if length($raw_certificate_id) && !length($certificate_id);
764
+
765
+    my $dbh = dbh();
766
+    return send_json($client, 404, { error => 'host_not_found' })
767
+        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
768
+    if (length $certificate_id) {
769
+        return send_json($client, 400, { error => 'invalid_certificate' })
770
+            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
771
+    }
772
+
773
+    my $now = iso_now();
774
+    with_transaction($dbh, sub {
775
+        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
776
+        set_schema_meta($dbh, 'registry_updated_at', $now);
777
+    });
778
+    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
779
+}
780
+
781
+sub issue_host_certificate {
782
+    my ($client, $payload) = @_;
783
+    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
784
+    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
785
+
786
+    my $registry = load_registry();
787
+    my ($host) = grep { canonical_host_fqdn($_) eq $host_fqdn } @{ $registry->{hosts} || [] };
788
+    return send_json($client, 404, { error => 'host_not_found' }) unless $host;
789
+
790
+    my @dns_names = unique_preserve(grep { length $_ } (
791
+        $host_fqdn,
792
+        declared_alias_names($host),
793
+        derived_alias_names($host),
794
+    ));
795
+    my $certificate_id = clean_certificate_id($host_fqdn . '-' . strftime('%Y%m%d%H%M%S', localtime));
796
+    my $dbh = dbh();
797
+    my $issued = eval {
798
+        ca_manager_output('issue', $certificate_id, @dns_names);
799
+        ca_manager_json('list-json');
800
+        with_transaction($dbh, sub {
801
+            my $now = iso_now();
802
+            upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
803
+            set_schema_meta($dbh, 'registry_updated_at', $now);
804
+        });
805
+        1;
806
+    };
807
+    if (!$issued) {
808
+        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
809
+    }
810
+
811
+    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
812
+    return send_json($client, 200, {
813
+        ok => json_bool(1),
814
+        host_fqdn => $host_fqdn,
815
+        certificate_id => $certificate_id,
816
+        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
817
+    });
818
+}
819
+
708 820
 sub set_vhost_certificate {
709 821
     my ($client, $payload) = @_;
710 822
     my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
@@ -809,7 +921,7 @@ sub analyze_hosts {
809 921
 }
810 922
 
811 923
 sub host_payload {
812
-    my ($host) = @_;
924
+    my ($host, $tls) = @_;
813 925
     my %copy = %$host;
814 926
     $copy{fqdn} = canonical_host_fqdn($host);
815 927
     $copy{ip} = canonical_ip($host);
@@ -819,6 +931,8 @@ sub host_payload {
819 931
     $copy{derived_aliases} = [ derived_alias_names($host) ];
820 932
     $copy{vhosts} = [ declared_vhost_names($host) ];
821 933
     $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
934
+    $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
935
+    $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
822 936
     return \%copy;
823 937
 }
824 938
 
@@ -1969,6 +2083,22 @@ CREATE TABLE IF NOT EXISTS host_ssh (
1969 2083
     PRIMARY KEY (host_fqdn, profile_name),
1970 2084
     FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1971 2085
 )
2086
+SQL
2087
+    $dbh->do(<<'SQL');
2088
+CREATE TABLE IF NOT EXISTS host_tls (
2089
+    host_fqdn TEXT PRIMARY KEY,
2090
+    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2091
+    certificate_id TEXT,
2092
+    notes TEXT NOT NULL DEFAULT '',
2093
+    created_at TEXT NOT NULL,
2094
+    updated_at TEXT NOT NULL,
2095
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE CASCADE,
2096
+    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2097
+)
2098
+SQL
2099
+    $dbh->do(<<'SQL');
2100
+CREATE INDEX IF NOT EXISTS idx_host_tls_certificate
2101
+ON host_tls(certificate_id)
1972 2102
 SQL
1973 2103
     $dbh->do(<<'SQL');
1974 2104
 CREATE TABLE IF NOT EXISTS certificates (
@@ -2255,6 +2385,22 @@ sub upsert_host_to_db {
2255 2385
     return $fqdn;
2256 2386
 }
2257 2387
 
2388
+sub upsert_host_tls_row {
2389
+    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2390
+    $certificate_id = clean_certificate_id($certificate_id || '');
2391
+    $dbh->do(
2392
+        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2393
+        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2394
+        undef,
2395
+        $host_fqdn,
2396
+        length($certificate_id) ? 'local-ca' : 'none',
2397
+        length($certificate_id) ? $certificate_id : undef,
2398
+        '',
2399
+        $now,
2400
+        $now,
2401
+    );
2402
+}
2403
+
2258 2404
 sub sync_host_values {
2259 2405
     my ($dbh, $table, $column, $fqdn, $values) = @_;
2260 2406
     my $now = iso_now();
@@ -3052,6 +3198,14 @@ sub app_html {
3052 3198
     .debug-section { display: grid; gap: 16px; }
3053 3199
     .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
3054 3200
     .host-tools input { max-width: 240px; }
3201
+    .host-alias-cell { display: grid; gap: 5px; min-width: 0; }
3202
+    .host-alias-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3203
+    .host-alias-pill { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin: 0; }
3204
+    .host-alias-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3205
+    .host-alias-remove, .host-alias-add { min-height: 28px; padding: 3px 7px; font-size: 12px; }
3206
+    .host-alias-remove { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--bad); }
3207
+    .host-alias-remove:hover { background: transparent; }
3208
+    .host-cert-cell { min-width: 0; }
3055 3209
     #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3056 3210
     #page-vhosts .host-tools { flex-wrap: wrap; }
3057 3211
     #page-vhosts .host-tools input { max-width: 280px; }
@@ -3181,10 +3335,10 @@ sub app_html {
3181 3335
             <table>
3182 3336
               <thead>
3183 3337
                 <tr>
3184
-                  <th style="width: 120px">ID</th>
3185 3338
                   <th style="width: 140px">IP</th>
3186
-                  <th>Names</th>
3339
+                  <th>Aliases</th>
3187 3340
                   <th style="width: 150px">Roles</th>
3341
+                  <th style="width: 260px">Certificate</th>
3188 3342
                   <th style="width: 110px">Monitoring</th>
3189 3343
                   <th style="width: 90px">Status</th>
3190 3344
                   <th style="width: 90px">Actions</th>
@@ -3356,7 +3510,7 @@ sub app_html {
3356 3510
         </div>
3357 3511
       </div>
3358 3512
       <form id="host-form" class="grid">
3359
-        <label>ID<input name="id" required></label>
3513
+        <label>Legacy ID<input name="id" required></label>
3360 3514
         <label>FQDN<input name="fqdn" required></label>
3361 3515
         <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3362 3516
         <label>IP<input name="ip" required></label>
@@ -3833,16 +3987,16 @@ sub app_html {
3833 3987
       const filter = $('filter').value.toLowerCase();
3834 3988
       $('hosts').innerHTML = state.hosts
3835 3989
         .slice()
3836
-        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3990
+        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
3837 3991
         .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3838 3992
         .map(h => {
3839 3993
           const problems = state.problems.filter(p => p.host_id === h.id);
3840 3994
           const cls = problems.length ? 'warn' : 'ok';
3841
-          return `<tr data-id="${escapeHtml(h.id)}">
3842
-            <td>${escapeHtml(h.id)}</td>
3995
+          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
3843 3996
             <td>${escapeHtml(h.ip || '')}</td>
3844
-            <td>${renderNamePills(h)}</td>
3997
+            <td>${renderHostAliasCell(h)}</td>
3845 3998
             <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3999
+            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
3846 4000
             <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3847 4001
             <td>${escapeHtml(h.status || '')}</td>
3848 4002
             <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
@@ -3853,14 +4007,76 @@ sub app_html {
3853 4007
           if (!isAuthLost(e)) msg(e.message);
3854 4008
         });
3855 4009
       }));
4010
+      document.querySelectorAll('[data-host-alias-add]').forEach(button => button.addEventListener('click', () => {
4011
+        addHostAlias(button.dataset.hostAliasAdd || '').catch(e => {
4012
+          if (!isAuthLost(e)) msg(e.message);
4013
+        });
4014
+      }));
4015
+      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4016
+        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4017
+          if (!isAuthLost(e)) msg(e.message);
4018
+        });
4019
+      }));
4020
+      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4021
+        select.addEventListener('change', () => {
4022
+          setHostCertificateFromSelect(select).catch(e => {
4023
+            if (!isAuthLost(e)) msg(e.message);
4024
+            select.value = select.dataset.currentCertificate || '';
4025
+          });
4026
+        });
4027
+      });
4028
+      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4029
+        button.addEventListener('click', () => {
4030
+          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4031
+            if (!isAuthLost(e)) msg(e.message);
4032
+          });
4033
+        });
4034
+      });
3856 4035
       mountHostEditor();
3857 4036
     }
3858 4037
 
3859
-    function renderNamePills(host) {
3860
-      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3861
-      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3862
-      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
3863
-      return canonical + aliases + derivedAliases;
4038
+    function renderHostAliasCell(host) {
4039
+      const canonical = host.fqdn ? `<span class="pill canonical host-alias-pill"><span class="host-alias-label">${escapeHtml(host.fqdn)}</span></span>` : '';
4040
+      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4041
+        <span class="host-alias-label">${escapeHtml(name)}</span>
4042
+        <button type="button" class="host-alias-remove" data-host-alias-remove="${escapeHtml(host.fqdn || '')}" data-host-alias-name="${escapeHtml(name)}" title="Delete ${escapeHtml(name)}">x</button>
4043
+      </span>`).join('');
4044
+      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived host-alias-pill" title="derived alias">
4045
+        <span class="host-alias-label">${escapeHtml(name)}</span>
4046
+      </span>`).join('');
4047
+      return `<div class="host-alias-cell">
4048
+        <div class="host-alias-list">${canonical}${aliases}${derivedAliases}<button type="button" class="host-alias-add" data-host-alias-add="${escapeHtml(host.fqdn || '')}" title="Add alias">+</button></div>
4049
+      </div>`;
4050
+    }
4051
+
4052
+    function renderHostCertificateCell(host) {
4053
+      const cert = host.certificate || {};
4054
+      const certId = host.certificate_id || certId(cert) || '';
4055
+      const row = hostCertificateRow(host);
4056
+      const links = certId ? `<div class="vhost-cert-links">
4057
+        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4058
+        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4059
+      </div>` : '';
4060
+      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4061
+      return `<div class="vhost-cert">
4062
+        <div class="vhost-cert-main">
4063
+          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4064
+            ${renderCertificateOptions(certId, row)}
4065
+          </select>
4066
+          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4067
+        </div>
4068
+        <div class="vhost-cert-meta">${links}${validity}</div>
4069
+      </div>`;
4070
+    }
4071
+
4072
+    function hostCertificateRow(host) {
4073
+      return {
4074
+        host_fqdn: host.fqdn || '',
4075
+        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4076
+        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4077
+        certificate_id: host.certificate_id || '',
4078
+        certificate: host.certificate || null,
4079
+      };
3864 4080
     }
3865 4081
 
3866 4082
     function vhostRows() {
@@ -4025,12 +4241,21 @@ sub app_html {
4025 4241
       const id = String(certId(cert)).toLowerCase();
4026 4242
       const commonName = String(cert.common_name || '').toLowerCase();
4027 4243
       const vhost = String(row.vhost || '').toLowerCase();
4028
-      const host = String(row.host_fqdn || '').toLowerCase();
4244
+      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
4029 4245
       const vhostShort = shortAliasForFqdn(vhost);
4030
-      const hostShort = shortAliasForFqdn(host);
4031
-      if (vhost && (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-'))) return 0;
4032
-      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4033
-      if ((vhostShort && names.has(vhostShort)) || (hostShort && names.has(hostShort))) return 2;
4246
+      const aliasNames = []
4247
+        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4248
+        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4249
+        .map(name => String(name || '').toLowerCase())
4250
+        .filter(Boolean);
4251
+      if (vhost) {
4252
+        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4253
+        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4254
+        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4255
+        return 9;
4256
+      }
4257
+      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4258
+      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
4034 4259
       return 9;
4035 4260
     }
4036 4261
 
@@ -4044,9 +4269,14 @@ sub app_html {
4044 4269
       const days = daysUntil(cert.not_after);
4045 4270
       const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
4046 4271
       const timestamp = id.match(/-(\d{14})$/);
4047
-      if (relevance === 0) return `vhost${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4048
-      if (relevance === 1) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4049
-      if (relevance === 2) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4272
+      if (row && row.vhost) {
4273
+        if (relevance === 0) return `vhost${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4274
+        if (relevance === 1) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4275
+        if (relevance === 2) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4276
+      } else {
4277
+        if (relevance === 0) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4278
+        if (relevance === 1) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4279
+      }
4050 4280
       return `${shortCertificateName(cert)}${suffix}`;
4051 4281
     }
4052 4282
 
@@ -4062,6 +4292,109 @@ sub app_html {
4062 4292
       return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
4063 4293
     }
4064 4294
 
4295
+    function hostByFqdn(fqdn) {
4296
+      fqdn = String(fqdn || '').toLowerCase();
4297
+      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4298
+    }
4299
+
4300
+    function hostUpsertPayload(host, overrides = {}) {
4301
+      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4302
+      const payload = {
4303
+        id: host.id || '',
4304
+        fqdn: host.fqdn || '',
4305
+        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4306
+        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4307
+        aliases,
4308
+        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
4309
+        sources: Array.isArray(overrides.sources) ? overrides.sources : (host.sources || []),
4310
+        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4311
+        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4312
+      };
4313
+      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4314
+      return payload;
4315
+    }
4316
+
4317
+    async function addHostAlias(hostFqdn) {
4318
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4319
+      const host = hostByFqdn(hostFqdn);
4320
+      if (!host) return;
4321
+      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4322
+      if (!alias) return;
4323
+      if (alias === String(host.fqdn || '').toLowerCase()) {
4324
+        msg('fqdn-ul hostului este deja prezent');
4325
+        return;
4326
+      }
4327
+      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4328
+      await api('/api/hosts/upsert', {
4329
+        method: 'POST',
4330
+        headers: { 'Content-Type': 'application/json' },
4331
+        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4332
+      });
4333
+      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4334
+      await refresh();
4335
+    }
4336
+
4337
+    async function removeHostAlias(hostFqdn, alias) {
4338
+      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4339
+      const host = hostByFqdn(hostFqdn);
4340
+      alias = String(alias || '').trim().toLowerCase();
4341
+      if (!host || !alias) return;
4342
+      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4343
+      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4344
+      await api('/api/hosts/upsert', {
4345
+        method: 'POST',
4346
+        headers: { 'Content-Type': 'application/json' },
4347
+        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4348
+      });
4349
+      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4350
+      await refresh();
4351
+    }
4352
+
4353
+    async function setHostCertificateFromSelect(select) {
4354
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4355
+        select.value = select.dataset.currentCertificate || '';
4356
+        return;
4357
+      }
4358
+      const hostFqdn = select.dataset.hostCertSelect || '';
4359
+      const certificateId = select.value || '';
4360
+      const current = select.dataset.currentCertificate || '';
4361
+      if (!hostFqdn || certificateId === current) return;
4362
+      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4363
+        select.value = current;
4364
+        return;
4365
+      }
4366
+      select.disabled = true;
4367
+      try {
4368
+        await api('/api/hosts/certificate', {
4369
+          method: 'POST',
4370
+          headers: { 'Content-Type': 'application/json' },
4371
+          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4372
+        });
4373
+        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4374
+        await refresh();
4375
+      } finally {
4376
+        select.disabled = false;
4377
+      }
4378
+    }
4379
+
4380
+    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4381
+      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4382
+      if (!hostFqdn) return;
4383
+      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4384
+      if (button) button.disabled = true;
4385
+      try {
4386
+        const result = await api('/api/hosts/issue-certificate', {
4387
+          method: 'POST',
4388
+          headers: { 'Content-Type': 'application/json' },
4389
+          body: JSON.stringify({ host_fqdn: hostFqdn }),
4390
+        });
4391
+        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4392
+        await refresh();
4393
+      } finally {
4394
+        if (button) button.disabled = false;
4395
+      }
4396
+    }
4397
+
4065 4398
     async function reassignVhostFromSelect(select) {
4066 4399
       const vhost = select.dataset.vhostSelect || '';
4067 4400
       const fromHost = select.dataset.currentHost || '';
@@ -4174,7 +4507,7 @@ sub app_html {
4174 4507
       hostField('aliases').value = (host.aliases || []).join('\n');
4175 4508
       hostField('roles').value = (host.roles || []).join(' ');
4176 4509
       hostField('sources').value = (host.sources || []).join(' ');
4177
-      activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
4510
+      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
4178 4511
     }
4179 4512
 
4180 4513
     async function newHost() {
@@ -4497,7 +4830,7 @@ sub app_html {
4497 4830
           hostField('aliases').value = (host.aliases || []).join('\n');
4498 4831
           hostField('roles').value = (host.roles || []).join(' ');
4499 4832
           hostField('sources').value = (host.sources || []).join(' ');
4500
-          activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
4833
+          activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
4501 4834
         } else {
4502 4835
           closeHostForm(true);
4503 4836
         }