@@ -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 |
} |