Showing 1 changed files with 329 additions and 62 deletions
+329 -62
scripts/host_manager.pl
@@ -192,6 +192,10 @@ sub handle_client {
192 192
         my $name = $1;
193 193
         return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
194 194
     }
195
+    if ($method eq 'GET' && $path =~ m{\A/download/ca/key/([A-Za-z0-9_.-]+)\.key\z}) {
196
+        my $name = $1;
197
+        return send_file($client, ca_issued_key_path($name), 'application/x-pem-file; charset=utf-8', "$name.key");
198
+    }
195 199
 
196 200
     if ($method eq 'POST' && $path =~ m{^/api/}) {
197 201
         if ($path eq '/api/hosts/upsert') {
@@ -214,6 +218,14 @@ sub handle_client {
214 218
             my $payload = request_payload(\%headers, $body);
215 219
             return delete_vhost($client, $payload);
216 220
         }
221
+        if ($path eq '/api/vhosts/certificate') {
222
+            my $payload = request_payload(\%headers, $body);
223
+            return set_vhost_certificate($client, $payload);
224
+        }
225
+        if ($path eq '/api/vhosts/issue-certificate') {
226
+            my $payload = request_payload(\%headers, $body);
227
+            return issue_vhost_certificate($client, $payload);
228
+        }
217 229
         if ($path eq '/api/work-orders/confirm') {
218 230
             my $payload = request_payload(\%headers, $body);
219 231
             return confirm_work_order($client, $payload);
@@ -399,21 +411,117 @@ sub registry_payload {
399 411
     my ($registry) = @_;
400 412
     my $problems = analyze_hosts($registry->{hosts});
401 413
     my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
414
+    my $dbh = dbh();
415
+    my @vhosts = vhost_payloads($dbh);
416
+    my @certificates = certificate_payloads($dbh);
402 417
     my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
403 418
     return {
404 419
         version => $registry->{version},
405 420
         updated_at => $registry->{updated_at},
406 421
         policy => $registry->{policy},
407 422
         hosts => \@hosts,
423
+        vhosts => \@vhosts,
424
+        certificates => \@certificates,
408 425
         problems => $problems,
409 426
         counts => {
410 427
             hosts => scalar @{ $registry->{hosts} },
411
-            vhosts => $vhost_count,
428
+            vhosts => scalar(@vhosts) || $vhost_count,
412 429
             problems => scalar @$problems,
413 430
         },
414 431
     };
415 432
 }
416 433
 
434
+sub vhost_payloads {
435
+    my ($dbh) = @_;
436
+    my @rows;
437
+    my $sth = $dbh->prepare(<<'SQL');
438
+SELECT
439
+    v.vhost_fqdn,
440
+    v.host_fqdn,
441
+    v.status AS vhost_status,
442
+    v.certificate_id,
443
+    h.legacy_id,
444
+    h.hosts_ip,
445
+    h.dns_ip,
446
+    h.monitoring,
447
+    h.status AS host_status,
448
+    c.common_name,
449
+    c.not_after,
450
+    c.fingerprint_sha256,
451
+    c.status AS certificate_status
452
+FROM vhosts v
453
+JOIN hosts h ON h.fqdn = v.host_fqdn
454
+LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
455
+WHERE v.status = 'active'
456
+ORDER BY v.vhost_fqdn
457
+SQL
458
+    $sth->execute;
459
+    while (my $row = $sth->fetchrow_hashref) {
460
+        my $cert_id = clean_scalar($row->{certificate_id} || '');
461
+        my %certificate = $cert_id ? (
462
+            id => $cert_id,
463
+            name => $cert_id,
464
+            common_name => clean_scalar($row->{common_name} || ''),
465
+            status => clean_scalar($row->{certificate_status} || ''),
466
+            not_after => clean_scalar($row->{not_after} || ''),
467
+            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
468
+            has_private_key => json_bool(-f ca_issued_key_path($cert_id) ? 1 : 0),
469
+        ) : ();
470
+        push @rows, {
471
+            vhost => $row->{vhost_fqdn},
472
+            vhost_fqdn => $row->{vhost_fqdn},
473
+            host_id => $row->{legacy_id} || '',
474
+            host_fqdn => $row->{host_fqdn},
475
+            ip => $row->{hosts_ip} || $row->{dns_ip} || '',
476
+            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
477
+            monitoring => $row->{monitoring} || '',
478
+            status => $row->{host_status} || $row->{vhost_status} || '',
479
+            vhost_status => $row->{vhost_status} || '',
480
+            certificate_id => $cert_id,
481
+            certificate => $cert_id ? \%certificate : undef,
482
+        };
483
+    }
484
+    return @rows;
485
+}
486
+
487
+sub certificate_payloads {
488
+    my ($dbh) = @_;
489
+    my @certificates;
490
+    my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
491
+    $sth->execute('retired');
492
+    while (my $row = $sth->fetchrow_hashref) {
493
+        my $id = clean_scalar($row->{certificate_id} || '');
494
+        next unless $id;
495
+        push @certificates, {
496
+            id => $id,
497
+            name => $id,
498
+            host_fqdn => $row->{host_fqdn} || '',
499
+            common_name => $row->{common_name} || '',
500
+            subject => $row->{subject} || '',
501
+            issuer => $row->{issuer} || '',
502
+            serial => $row->{serial} || '',
503
+            status => $row->{status} || '',
504
+            not_before => $row->{not_before} || '',
505
+            not_after => $row->{not_after} || '',
506
+            fingerprint_sha256 => $row->{fingerprint_sha256} || '',
507
+            dns_names => [ certificate_dns_names($dbh, $id) ],
508
+            has_private_key => json_bool(-f ca_issued_key_path($id) ? 1 : 0),
509
+        };
510
+    }
511
+    return @certificates;
512
+}
513
+
514
+sub certificate_dns_names {
515
+    my ($dbh, $certificate_id) = @_;
516
+    my @names;
517
+    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
518
+    $sth->execute($certificate_id);
519
+    while (my ($name) = $sth->fetchrow_array) {
520
+        push @names, $name;
521
+    }
522
+    return @names;
523
+}
524
+
417 525
 sub upsert_host {
418 526
     my ($client, $payload) = @_;
419 527
     my $id = clean_id($payload->{id} || '');
@@ -597,6 +705,82 @@ sub delete_vhost {
597 705
     return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
598 706
 }
599 707
 
708
+sub set_vhost_certificate {
709
+    my ($client, $payload) = @_;
710
+    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
711
+    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
712
+    my $certificate_id = clean_certificate_id($raw_certificate_id);
713
+    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
714
+    return send_json($client, 400, { error => 'invalid_certificate' })
715
+        if length($raw_certificate_id) && !length($certificate_id);
716
+
717
+    my $dbh = dbh();
718
+    return send_json($client, 404, { error => 'vhost_not_found' })
719
+        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
720
+    if (length $certificate_id) {
721
+        return send_json($client, 400, { error => 'invalid_certificate' })
722
+            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
723
+    }
724
+
725
+    my $now = iso_now();
726
+    $dbh->do(
727
+        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
728
+        undef,
729
+        length($certificate_id) ? $certificate_id : undef,
730
+        length($certificate_id) ? 'local-ca' : 'none',
731
+        $now,
732
+        $vhost,
733
+        'active',
734
+    );
735
+    set_schema_meta($dbh, 'registry_updated_at', $now);
736
+    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
737
+}
738
+
739
+sub issue_vhost_certificate {
740
+    my ($client, $payload) = @_;
741
+    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
742
+    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
743
+
744
+    my $dbh = dbh();
745
+    my ($host_fqdn) = $dbh->selectrow_array(
746
+        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
747
+        undef,
748
+        $vhost,
749
+    );
750
+    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
751
+
752
+    my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
753
+    my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
754
+    my $issued = eval {
755
+        ca_manager_output('issue', $certificate_id, @dns_names);
756
+        ca_manager_json('list-json');
757
+        with_transaction($dbh, sub {
758
+            my $now = iso_now();
759
+            $dbh->do(
760
+                "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
761
+                undef,
762
+                $certificate_id,
763
+                $now,
764
+                $vhost,
765
+            );
766
+            set_schema_meta($dbh, 'registry_updated_at', $now);
767
+        });
768
+        1;
769
+    };
770
+    if (!$issued) {
771
+        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
772
+    }
773
+
774
+    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
775
+    return send_json($client, 200, {
776
+        ok => json_bool(1),
777
+        vhost_fqdn => $vhost,
778
+        host_fqdn => $host_fqdn,
779
+        certificate_id => $certificate_id,
780
+        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
781
+    });
782
+}
783
+
600 784
 sub analyze_hosts {
601 785
     my ($hosts) = @_;
602 786
     my @problems;
@@ -980,15 +1164,27 @@ sub ca_issued_cert_path {
980 1164
     return ca_dir() . "/issued/$name.cert.pem";
981 1165
 }
982 1166
 
983
-sub ca_manager_json {
984
-    my ($command) = @_;
1167
+sub ca_issued_key_path {
1168
+    my ($name) = @_;
1169
+    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1170
+    return ca_dir() . "/issued/$name.key.pem";
1171
+}
1172
+
1173
+sub ca_manager_output {
1174
+    my (@args) = @_;
985 1175
     my $script = ca_script_path();
986 1176
     die "CA manager script is missing\n" unless -x $script;
987 1177
     local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
988
-    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
1178
+    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
989 1179
     local $/;
990 1180
     my $out = <$fh>;
991 1181
     close $fh or die "CA manager failed\n";
1182
+    return $out || '';
1183
+}
1184
+
1185
+sub ca_manager_json {
1186
+    my ($command) = @_;
1187
+    my $out = ca_manager_output($command);
992 1188
     $out ||= $command eq 'list-json' ? '[]' : '{}';
993 1189
     sync_certificates_from_json($out) if $command eq 'list-json';
994 1190
     return $out;
@@ -1443,6 +1639,13 @@ sub clean_id {
1443 1639
     return $value;
1444 1640
 }
1445 1641
 
1642
+sub clean_certificate_id {
1643
+    my ($value) = @_;
1644
+    $value = clean_scalar($value);
1645
+    return '' unless length $value;
1646
+    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1647
+}
1648
+
1446 1649
 sub clean_scalar {
1447 1650
     my ($value) = @_;
1448 1651
     $value = '' unless defined $value;
@@ -2853,9 +3056,11 @@ sub app_html {
2853 3056
     .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
2854 3057
     .vhost-inline-editor { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
2855 3058
     .vhost-delete { color: var(--bad); }
2856
-    .host-editor-panel { margin-top: 16px; }
2857
-    .host-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
2858
-    .host-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
3059
+    .host-inline-row td { padding: 0; background: #fff; }
3060
+    .host-inline-editor-shell { background: #fff; }
3061
+    .host-inline-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fafbfc; }
3062
+    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3063
+    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
2859 3064
     .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2860 3065
     .form-message.error { color: var(--bad); }
2861 3066
     .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -2867,8 +3072,8 @@ sub app_html {
2867 3072
       .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2868 3073
       .host-tools input { max-width: none; }
2869 3074
       .vhost-inline-editor { grid-template-columns: 1fr; }
2870
-      .host-editor-head { align-items: stretch; flex-direction: column; }
2871
-      .host-editor-tools { justify-content: flex-start; }
3075
+      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3076
+      .host-inline-editor-tools { justify-content: flex-start; }
2872 3077
       .debug-controls { align-items: stretch; }
2873 3078
       .grid { grid-template-columns: 1fr; }
2874 3079
       table { min-width: 760px; }
@@ -2963,36 +3168,13 @@ sub app_html {
2963 3168
                   <th style="width: 150px">Roles</th>
2964 3169
                   <th style="width: 110px">Monitoring</th>
2965 3170
                   <th style="width: 90px">Status</th>
3171
+                  <th style="width: 90px">Actions</th>
2966 3172
                 </tr>
2967 3173
               </thead>
2968 3174
               <tbody id="hosts"></tbody>
2969 3175
             </table>
2970 3176
           </div>
2971 3177
         </section>
2972
-        <section class="panel host-editor-panel">
2973
-          <div class="panel-head host-editor-head">
2974
-            <h2 id="host-form-title">New host</h2>
2975
-            <div class="host-editor-tools">
2976
-              <button type="button" id="reset-host-form">Reset</button>
2977
-            </div>
2978
-          </div>
2979
-          <form id="host-form" class="grid">
2980
-            <label>ID<input name="id" required></label>
2981
-            <label>FQDN<input name="fqdn" required></label>
2982
-            <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2983
-            <label>IP<input name="ip" required></label>
2984
-            <label class="span2">Aliases<textarea name="aliases"></textarea></label>
2985
-            <label>Roles<input name="roles"></label>
2986
-            <label>Sources<input name="sources"></label>
2987
-            <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2988
-            <label>Notes<input name="notes"></label>
2989
-            <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
2990
-            <div class="span2 form-actions">
2991
-              <button class="primary" type="submit" id="save-host">Save host</button>
2992
-              <button class="danger" type="button" id="delete-host">Delete host</button>
2993
-            </div>
2994
-          </form>
2995
-        </section>
2996 3178
       </section>
2997 3179
 
2998 3180
       <section class="page" id="page-vhosts" data-page="vhosts" hidden>
@@ -3140,9 +3322,49 @@ sub app_html {
3140 3322
     let hostFormSnapshot = '';
3141 3323
     let hostFormBusy = false;
3142 3324
     let hostFormMode = 'new';
3325
+    let hostEditorTarget = '';
3143 3326
 
3144 3327
     const $ = (id) => document.getElementById(id);
3145 3328
     const msg = (text) => { $('message').textContent = text || ''; };
3329
+    const hostFormShell = document.createElement('div');
3330
+    hostFormShell.id = 'host-form-shell';
3331
+    hostFormShell.className = 'host-inline-editor-shell';
3332
+    hostFormShell.hidden = true;
3333
+    hostFormShell.innerHTML = `
3334
+      <div class="host-inline-editor-head">
3335
+        <h2 id="host-form-title">New host</h2>
3336
+        <div class="host-inline-editor-tools">
3337
+          <button type="button" id="cancel-host-form">Close</button>
3338
+        </div>
3339
+      </div>
3340
+      <form id="host-form" class="grid">
3341
+        <label>ID<input name="id" required></label>
3342
+        <label>FQDN<input name="fqdn" required></label>
3343
+        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3344
+        <label>IP<input name="ip" required></label>
3345
+        <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3346
+        <label>Roles<input name="roles"></label>
3347
+        <label>Sources<input name="sources"></label>
3348
+        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3349
+        <label>Notes<input name="notes"></label>
3350
+        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3351
+        <div class="span2 form-actions">
3352
+          <button class="primary" type="submit" id="save-host">Save host</button>
3353
+          <button class="danger" type="button" id="delete-host">Delete host</button>
3354
+        </div>
3355
+      </form>`;
3356
+    const hostForm = hostFormShell.querySelector('#host-form');
3357
+    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3358
+    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3359
+    const saveHostButton = hostFormShell.querySelector('#save-host');
3360
+    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3361
+    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
3362
+    const hostEditorRow = document.createElement('tr');
3363
+    hostEditorRow.className = 'host-inline-row';
3364
+    const hostEditorCell = document.createElement('td');
3365
+    hostEditorCell.colSpan = 7;
3366
+    hostEditorRow.appendChild(hostEditorCell);
3367
+    hostEditorCell.appendChild(hostFormShell);
3146 3368
     const PAGE_PATHS = {
3147 3369
       '/': 'overview',
3148 3370
       '/overview': 'overview',
@@ -3586,12 +3808,13 @@ sub app_html {
3586 3808
           const problems = state.problems.filter(p => p.host_id === h.id);
3587 3809
           const cls = problems.length ? 'warn' : 'ok';
3588 3810
           return `<tr data-id="${escapeHtml(h.id)}">
3589
-            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3811
+            <td>${escapeHtml(h.id)}</td>
3590 3812
             <td>${escapeHtml(h.ip || '')}</td>
3591 3813
             <td>${renderNamePills(h)}</td>
3592 3814
             <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3593 3815
             <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3594 3816
             <td>${escapeHtml(h.status || '')}</td>
3817
+            <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
3595 3818
           </tr>`;
3596 3819
         }).join('');
3597 3820
       document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
@@ -3599,6 +3822,7 @@ sub app_html {
3599 3822
           if (!isAuthLost(e)) msg(e.message);
3600 3823
         });
3601 3824
       }));
3825
+      mountHostEditor();
3602 3826
     }
3603 3827
 
3604 3828
     function renderNamePills(host) {
@@ -3747,44 +3971,87 @@ sub app_html {
3747 3971
       if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
3748 3972
       const host = state.hosts.find(h => h.id === id);
3749 3973
       if (!host) return;
3974
+      if (!canSwitchHostEditor(id)) return;
3750 3975
       clearHostFormMessage();
3751 3976
       for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3752 3977
       hostField('aliases').value = (host.aliases || []).join('\n');
3753 3978
       hostField('roles').value = (host.roles || []).join(' ');
3754 3979
       hostField('sources').value = (host.sources || []).join(' ');
3755
-      activateHostForm('Edit host', 'edit', 'fqdn');
3980
+      activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
3756 3981
     }
3757 3982
 
3758 3983
     async function newHost() {
3759 3984
       if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
3760
-      resetHostForm(false, true);
3985
+      if (!canSwitchHostEditor('__new__')) return;
3986
+      resetHostForm(true);
3987
+      activateHostForm('New host', 'new', '__new__', 'id');
3761 3988
     }
3762 3989
 
3763
-    function activateHostForm(title, mode, focusField = 'id', scroll = true) {
3990
+    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
3764 3991
       hostFormMode = mode || 'new';
3765
-      $('host-form-title').textContent = title || 'New host';
3766
-      hostFormSnapshot = hostFormState();
3992
+      hostEditorTarget = target || '';
3993
+      hostFormTitle.textContent = title || 'New host';
3767 3994
       syncHostFormActions();
3768
-      if (scroll) $('host-form').scrollIntoView({ block: 'start', behavior: 'smooth' });
3995
+      renderHosts();
3996
+      hostFormSnapshot = hostFormState();
3997
+      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3769 3998
       hostField(focusField).focus();
3770 3999
     }
3771 4000
 
3772
-    function resetHostForm(force = false, scroll = false) {
4001
+    function resetHostForm(force = false) {
3773 4002
       if (hostFormBusy && !force) return;
3774
-      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3775
-      $('host-form').reset();
4003
+      hostForm.reset();
3776 4004
       clearHostFormMessage();
3777 4005
       hostField('status').value = 'active';
3778 4006
       hostField('monitoring').value = 'pending';
3779
-      activateHostForm('New host', 'new', 'id', scroll);
4007
+      hostFormSnapshot = force ? '' : hostFormState();
4008
+    }
4009
+
4010
+    function closeHostForm(force = false) {
4011
+      if (hostFormBusy && !force) return;
4012
+      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4013
+      hostEditorTarget = '';
4014
+      hostFormMode = 'new';
4015
+      hostFormSnapshot = '';
4016
+      clearHostFormMessage();
4017
+      syncHostFormActions();
4018
+      mountHostEditor();
4019
+    }
4020
+
4021
+    function canSwitchHostEditor(target) {
4022
+      if (hostFormBusy) return false;
4023
+      if (!hostEditorTarget) return true;
4024
+      if (!hostFormDirty()) return true;
4025
+      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4026
+      return confirm('Discard unsaved host changes?');
4027
+    }
4028
+
4029
+    function mountHostEditor() {
4030
+      hostEditorRow.remove();
4031
+      if (!hostEditorTarget) {
4032
+        hostFormShell.hidden = true;
4033
+        return;
4034
+      }
4035
+      hostEditorCell.colSpan = 7;
4036
+      const tbody = $('hosts');
4037
+      if (!tbody) return;
4038
+      if (hostEditorTarget === '__new__') {
4039
+        tbody.prepend(hostEditorRow);
4040
+      } else {
4041
+        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4042
+        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4043
+        if (targetRow) targetRow.after(hostEditorRow);
4044
+        else tbody.prepend(hostEditorRow);
4045
+      }
4046
+      hostFormShell.hidden = false;
3780 4047
     }
3781 4048
 
3782 4049
     function hostField(name) {
3783
-      return $('host-form').elements.namedItem(name);
4050
+      return hostForm.elements.namedItem(name);
3784 4051
     }
3785 4052
 
3786 4053
     function hostFormState() {
3787
-      return JSON.stringify(formObject($('host-form')));
4054
+      return JSON.stringify(formObject(hostForm));
3788 4055
     }
3789 4056
 
3790 4057
     function hostFormDirty() {
@@ -3797,15 +4064,14 @@ sub app_html {
3797 4064
     }
3798 4065
 
3799 4066
     function syncHostFormActions() {
3800
-      $('save-host').disabled = hostFormBusy;
3801
-      $('delete-host').disabled = hostFormBusy || hostFormMode !== 'edit';
3802
-      $('reset-host-form').disabled = hostFormBusy;
4067
+      saveHostButton.disabled = hostFormBusy;
4068
+      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4069
+      cancelHostButton.disabled = hostFormBusy;
3803 4070
     }
3804 4071
 
3805 4072
     function setHostFormMessage(text, isError = false) {
3806
-      const message = $('host-form-message');
3807
-      message.textContent = text || '';
3808
-      message.classList.toggle('error', !!isError);
4073
+      hostFormMessage.textContent = text || '';
4074
+      hostFormMessage.classList.toggle('error', !!isError);
3809 4075
     }
3810 4076
 
3811 4077
     function clearHostFormMessage() {
@@ -4015,9 +4281,9 @@ sub app_html {
4015 4281
     $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4016 4282
       if (!isAuthLost(e)) msg(e.message);
4017 4283
     }));
4018
-    $('reset-host-form').addEventListener('click', () => resetHostForm());
4284
+    cancelHostButton.addEventListener('click', () => closeHostForm());
4019 4285
 
4020
-    $('host-form').addEventListener('submit', async (event) => {
4286
+    hostForm.addEventListener('submit', async (event) => {
4021 4287
       event.preventDefault();
4022 4288
       if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
4023 4289
       setHostFormBusy(true);
@@ -4034,9 +4300,9 @@ sub app_html {
4034 4300
           hostField('aliases').value = (host.aliases || []).join('\n');
4035 4301
           hostField('roles').value = (host.roles || []).join(' ');
4036 4302
           hostField('sources').value = (host.sources || []).join(' ');
4037
-          activateHostForm('Edit host', 'edit', 'fqdn', false);
4303
+          activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
4038 4304
         } else {
4039
-          resetHostForm(true, false);
4305
+          closeHostForm(true);
4040 4306
         }
4041 4307
       } catch (e) {
4042 4308
         if (isAuthLost(e)) return;
@@ -4047,15 +4313,15 @@ sub app_html {
4047 4313
       }
4048 4314
     });
4049 4315
 
4050
-    $('host-form').addEventListener('invalid', (event) => {
4316
+    hostForm.addEventListener('invalid', (event) => {
4051 4317
       setHostFormMessage('Complete the required host fields before saving.', true);
4052 4318
     }, true);
4053 4319
 
4054
-    $('host-form').addEventListener('input', () => {
4055
-      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
4320
+    hostForm.addEventListener('input', () => {
4321
+      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
4056 4322
     });
4057 4323
 
4058
-    $('delete-host').addEventListener('click', async () => {
4324
+    deleteHostButton.addEventListener('click', async () => {
4059 4325
       const id = hostField('id').value;
4060 4326
       if (!id || !confirm(`Delete ${id}?`)) return;
4061 4327
       setHostFormBusy(true);
@@ -4064,7 +4330,7 @@ sub app_html {
4064 4330
         await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4065 4331
         msg('host deleted');
4066 4332
         await refresh();
4067
-        resetHostForm(true, false);
4333
+        closeHostForm(true);
4068 4334
       } catch (e) {
4069 4335
         if (isAuthLost(e)) return;
4070 4336
         setHostFormMessage(e.message, true);
@@ -4074,7 +4340,8 @@ sub app_html {
4074 4340
       }
4075 4341
     });
4076 4342
 
4077
-    resetHostForm(true, false);
4343
+    resetHostForm(true);
4344
+    closeHostForm(true);
4078 4345
 
4079 4346
     $('write-tsv').addEventListener('click', async () => {
4080 4347
       if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;