Showing 10 changed files with 454 additions and 148 deletions
+2 -2
.doc/database/tables/hosts.md
@@ -11,8 +11,8 @@ Canonical host registry. Hosts are identified by full DNS name in `fqdn`.
11 11
 | `fqdn` | `TEXT` | no | none | Canonical full host name. Primary key. |
12 12
 | `legacy_id` | `TEXT` | no | none | Short ID used by the existing UI/API. Unique. |
13 13
 | `status` | `TEXT` | no | `'active'` | Host lifecycle state, currently `active`, `planned`, or `retired`. |
14
-| `hosts_ip` | `TEXT` | no | `''` | IP used for `/etc/hosts` on jumper. |
15
-| `dns_ip` | `TEXT` | no | `''` | IP published to clients through local DNS. |
14
+| `hosts_ip` | `TEXT` | no | `''` | Legacy compatibility column. The current app model keeps one canonical routable IP and mirrors it here. |
15
+| `dns_ip` | `TEXT` | no | `''` | Canonical routable IP for the host. The current UI/API expose this as a single `ip` field. |
16 16
 | `monitoring` | `TEXT` | no | `'pending'` | Monitoring state. |
17 17
 | `notes` | `TEXT` | no | `''` | Operator notes. |
18 18
 | `created_at` | `TEXT` | no | none | ISO UTC creation timestamp. |
+1 -0
.doc/database/tables/vhosts.md
@@ -30,6 +30,7 @@ Stores virtual hosts separately from physical/logical hosts so a vhost can be mo
30 30
 ## Rules
31 31
 
32 32
 - Moving a vhost means updating `host_fqdn`.
33
+- The `certificate_id` binding stays on the vhost row when the vhost moves to another host.
33 34
 - Retiring a vhost means setting `status = 'retired'`; the row remains.
34 35
 
35 36
 ## Definition
+16 -0
.doc/development-logs/auth-login.md
@@ -2,6 +2,22 @@
2 2
 
3 3
 Decizii despre autentificare, OTP, sesiuni, formulare si compatibilitate cu password managere/autofill.
4 4
 
5
+## 2026-06-09 - Verificare sesiune inainte de fluxuri cu save
6
+
7
+Regula de development: orice operatie UI care poate ajunge la `save`/`submit` trebuie sa verifice starea de autentificare inainte ca operatorul sa inceapa editarea si din nou imediat inainte de salvare. Aceeasi regula se aplica la schimbarea taburilor/sectiunilor, pentru ca navigarea poate declansa incarcari sau actiuni protejate.
8
+
9
+Comportamentul asteptat:
10
+
11
+- daca sesiunea este valida, fluxul continua normal
12
+- daca sesiunea lipseste sau a expirat, aplicatia revine coerent la login
13
+- datele deja introduse intr-un formular nu trebuie resetate doar pentru ca autentificarea a expirat inainte de save
14
+
15
+Scop:
16
+
17
+- evitarea pierderii modificarilor locale din formulare
18
+- separarea clara intre erori de validare si sesiune expirata
19
+- evitarea operatiunilor partiale pe endpoint-uri protejate
20
+
5 21
 ## 2026-06-06 - OTP Login Keeps a Password-Manager-Friendly Form Shape
6 22
 
7 23
 Observatie: unele password managere si autofill-uri mobile nu initiau corect pe login-ul Madagascar Local Authority, desi completarea mergea pe o pagina similara din PBX management.
+10 -0
.doc/host-manager.md
@@ -153,6 +153,16 @@ Primul WO curent este pentru retragerea numelor locale `pmx.*`/`pbs.*` create is
153 153
 
154 154
 `madagascar.xdev.ro` este domeniul implicit al rețelei interne. Hosturile sunt identificate în baza de date prin FQDN complet, iar UI-ul păstrează temporar `id` ca identificator compatibil.
155 155
 
156
+Modelul curent de adresare păstrează un singur IP canonic per host. UI-ul și API-ul expun acest câmp ca `ip`, iar store-ul runtime îl mapează în coloanele istorice SQLite doar pentru compatibilitate internă.
157
+
158
+Modelul curent de naming separă explicit:
159
+
160
+- `fqdn` — numele canonic al hostului
161
+- `aliases` — nume suplimentare atașate hostului, care pot fi adăugate sau șterse
162
+- `vhosts` — nume virtuale servite de host, care pot fi adăugate, șterse sau mutate pe alt host
163
+
164
+Mutarea unui vhost înseamnă schimbarea `host_fqdn` în tabelul `vhosts`; legătura `certificate_id` de pe rândul vhostului rămâne atașată vhostului pe durata mutării.
165
+
156 166
 Pentru orice nume `*.madagascar.xdev.ro`, aplicația derivă automat aliasul scurt prin eliminarea sufixului `.madagascar.xdev.ro`.
157 167
 
158 168
 Exemple:
+7 -3
.doc/local-hosts.md
@@ -28,7 +28,7 @@ Implementarea versionată este:
28 28
 |--------|-----|
29 29
 | `var/host-manager.sqlite` | sursa de adevăr runtime pentru registry și Work Orders |
30 30
 | `config/hosts.yaml` | seed/snapshot export pentru hosturi și FQDN-uri canonice |
31
-| `config/local-hosts.tsv` | manifest DNS generat, cu aliasuri scurte derivate |
31
+| `config/local-hosts.tsv` | manifest DNS generat, cu un singur IP canonic pe host și aliasuri scurte derivate |
32 32
 | `scripts/sync_local_hosts.sh` | generează și sincronizează `/etc/hosts`, `cloaking-rules.txt` și `/ip dns static` |
33 33
 
34 34
 `madagascar.xdev.ro` este domeniul implicit. Pentru orice nume `*.madagascar.xdev.ro`, aliasul scurt este derivat automat. De exemplu, `autonas01.madagascar.xdev.ro` publică și `autonas01`, iar `pmx.baobab.madagascar.xdev.ro` publică și `pmx.baobab`. Aliasurile derivate nu se declară separat în registry.
@@ -121,12 +121,16 @@ dig @192.168.2.2   nohost.madagascar.xdev.ro +short
121 121
 
122 122
 Comenzile negative de mai sus trebuie să întoarcă output gol, iar cu `+comments` statusul trebuie să fie `NXDOMAIN`.
123 123
 
124
+## Politica de adresare
125
+
126
+Registry-ul de cluster publică un singur IP rutabil pentru fiecare host. Micromanagement-ul local, inclusiv orice intrări `127.0.0.1 localhost localhost.localdomain` sau override-uri locale pe host, nu se administrează din Madagascar Local Authority.
127
+
124 128
 ## Hosturi speciale — excepții
125 129
 
126 130
 | Hostname | /etc/hosts pe jumper | cloaking-rules | Motiv |
127 131
 |----------|------------------------|----------------|-------|
128 132
 | `mazeri.madagascar.xdev.ro` | 192.168.2.102 | 192.168.2.102 | Mașina Debian la .102, separată de jumper chiar dacă Exim folosește acest hostname |
129
-| `jumper.madagascar.xdev.ro` | 127.0.0.1 | 192.168.2.100 | Jumper se referă la sine prin loopback (necesar pentru Exim local delivery); clienții LAN primesc IP-ul real |
133
+| `jumper.madagascar.xdev.ro` | 192.168.2.100 | 192.168.2.100 | Registry-ul publică doar adresa rutabilă; orice comportament local special rămâne responsabilitatea hostului |
130 134
 
131 135
 ## Hosturi existente
132 136
 
@@ -134,7 +138,7 @@ Comenzile negative de mai sus trebuie să întoarcă output gol, iar cu `+commen
134 138
 |----------|----|
135 139
 | `baobab.madagascar.xdev.ro` | 192.168.10.91 |
136 140
 | `mazeri.madagascar.xdev.ro` | 192.168.2.102 |
137
-| `jumper.madagascar.xdev.ro` | 192.168.2.100 (127.0.0.1 local) |
141
+| `jumper.madagascar.xdev.ro` | 192.168.2.100 |
138 142
 | `hosts.madagascar.xdev.ro` | 192.168.2.100 (vhost nginx pe jumper) |
139 143
 | `zabbix.madagascar.xdev.ro` | 192.168.2.107 |
140 144
 | `toltec.madagascar.xdev.ro` | 192.168.2.103 |
+1 -0
agents.md
@@ -30,6 +30,7 @@ Operational rules:
30 30
 - Perl from the distribution and core/distribution modules are allowed.
31 31
 - CPAN modules are allowed only after requesting an audit and RPM packaging for the local audited repository.
32 32
 - Secrets live outside git, mainly under `/etc/xdev/host-manager.env` on jumper.
33
+- UI flows that can lead to a save must verify authentication before the user starts editing, before tab/section changes, and again before submit/save. If authentication is missing or expired, return to login without discarding in-progress form data.
33 34
 
34 35
 Before code changes:
35 36
 
+12 -23
config/hosts.yaml
@@ -10,8 +10,7 @@ policy:
10 10
 hosts:
11 11
   - id: "baobab"
12 12
     status: "active"
13
-    hosts_ip: "192.168.10.91"
14
-    dns_ip: "192.168.10.91"
13
+    ip: "192.168.10.91"
15 14
     names:
16 15
       - "baobab.madagascar.xdev.ro"
17 16
       - "pmx.baobab.madagascar.xdev.ro"
@@ -25,8 +24,7 @@ hosts:
25 24
     notes: "Service DNS uses thunderbridge."
26 25
   - id: "ebony"
27 26
     status: "active"
28
-    hosts_ip: "192.168.10.92"
29
-    dns_ip: "192.168.10.92"
27
+    ip: "192.168.10.92"
30 28
     names:
31 29
       - "ebony.madagascar.xdev.ro"
32 30
       - "pmx.ebony.madagascar.xdev.ro"
@@ -40,8 +38,7 @@ hosts:
40 38
     notes: "Service DNS uses thunderbridge."
41 39
   - id: "tapia"
42 40
     status: "active"
43
-    hosts_ip: "192.168.10.93"
44
-    dns_ip: "192.168.10.93"
41
+    ip: "192.168.10.93"
45 42
     names:
46 43
       - "tapia.madagascar.xdev.ro"
47 44
       - "pmx.tapia.madagascar.xdev.ro"
@@ -55,8 +52,7 @@ hosts:
55 52
     notes: "Service DNS uses thunderbridge."
56 53
   - id: "autonas01"
57 54
     status: "active"
58
-    hosts_ip: "192.168.10.21"
59
-    dns_ip: "192.168.10.21"
55
+    ip: "192.168.10.21"
60 56
     names:
61 57
       - "autonas01.madagascar.xdev.ro"
62 58
     roles:
@@ -67,8 +63,7 @@ hosts:
67 63
     notes: ""
68 64
   - id: "autonas02"
69 65
     status: "active"
70
-    hosts_ip: "192.168.10.22"
71
-    dns_ip: "192.168.10.22"
66
+    ip: "192.168.10.22"
72 67
     names:
73 68
       - "autonas02.madagascar.xdev.ro"
74 69
     roles:
@@ -79,8 +74,7 @@ hosts:
79 74
     notes: ""
80 75
   - id: "anjothibe"
81 76
     status: "active"
82
-    hosts_ip: "192.168.2.95"
83
-    dns_ip: "192.168.2.95"
77
+    ip: "192.168.2.95"
84 78
     names:
85 79
       - "anjothibe.madagascar.xdev.ro"
86 80
       - "pbs.anjothibe.madagascar.xdev.ro"
@@ -94,8 +88,7 @@ hosts:
94 88
     notes: ""
95 89
   - id: "andrafiabe"
96 90
     status: "active"
97
-    hosts_ip: "192.168.2.96"
98
-    dns_ip: "192.168.2.96"
91
+    ip: "192.168.2.96"
99 92
     names:
100 93
       - "andrafiabe.madagascar.xdev.ro"
101 94
       - "pbs.andrafiabe.madagascar.xdev.ro"
@@ -109,8 +102,7 @@ hosts:
109 102
     notes: ""
110 103
   - id: "mazeri"
111 104
     status: "active"
112
-    hosts_ip: "192.168.2.102"
113
-    dns_ip: "192.168.2.102"
105
+    ip: "192.168.2.102"
114 106
     names:
115 107
       - "mazeri.madagascar.xdev.ro"
116 108
     roles:
@@ -122,8 +114,7 @@ hosts:
122 114
     notes: ""
123 115
   - id: "toltec"
124 116
     status: "active"
125
-    hosts_ip: "192.168.2.103"
126
-    dns_ip: "192.168.2.103"
117
+    ip: "192.168.2.103"
127 118
     names:
128 119
       - "toltec.madagascar.xdev.ro"
129 120
     roles:
@@ -135,8 +126,7 @@ hosts:
135 126
     notes: ""
136 127
   - id: "zabbix"
137 128
     status: "active"
138
-    hosts_ip: "192.168.2.107"
139
-    dns_ip: "192.168.2.107"
129
+    ip: "192.168.2.107"
140 130
     names:
141 131
       - "zabbix.madagascar.xdev.ro"
142 132
     roles:
@@ -148,8 +138,7 @@ hosts:
148 138
     notes: ""
149 139
   - id: "jumper"
150 140
     status: "active"
151
-    hosts_ip: "127.0.0.1"
152
-    dns_ip: "192.168.2.100"
141
+    ip: "192.168.2.100"
153 142
     names:
154 143
       - "jumper.madagascar.xdev.ro"
155 144
       - "hosts.madagascar.xdev.ro"
@@ -160,4 +149,4 @@ hosts:
160 149
       - "local-hosts.tsv"
161 150
       - "hosts-local.yaml"
162 151
     monitoring: "enabled"
163
-    notes: "Loopback only for local delivery on jumper; LAN DNS gets 192.168.2.100."
152
+    notes: "Cluster registry publishes only the routable address."
+12 -12
config/local-hosts.tsv
@@ -2,20 +2,20 @@
2 2
 # Generated by scripts/host_manager.pl from config/hosts.yaml.
3 3
 #
4 4
 # Format:
5
-# hosts_ip<TAB>dns_ip<TAB>name [aliases...]
5
+# ip<TAB>name [aliases...]
6 6
 #
7 7
 # Priority rule:
8 8
 # - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.
9 9
 # - madagascar.json is canonical for cluster roles and service interfaces.
10 10
 # - This file publishes approved local DNS records derived from those sources.
11
-192.168.2.96	192.168.2.96	andrafiabe.madagascar.xdev.ro pbs.andrafiabe.madagascar.xdev.ro andrafiabe pbs.andrafiabe
12
-192.168.2.95	192.168.2.95	anjothibe.madagascar.xdev.ro pbs.anjothibe.madagascar.xdev.ro anjothibe pbs.anjothibe
13
-192.168.10.21	192.168.10.21	autonas01.madagascar.xdev.ro autonas01
14
-192.168.10.22	192.168.10.22	autonas02.madagascar.xdev.ro autonas02
15
-192.168.10.91	192.168.10.91	baobab.madagascar.xdev.ro pmx.baobab.madagascar.xdev.ro baobab pmx.baobab
16
-192.168.10.92	192.168.10.92	ebony.madagascar.xdev.ro pmx.ebony.madagascar.xdev.ro ebony pmx.ebony
17
-127.0.0.1	192.168.2.100	jumper.madagascar.xdev.ro hosts.madagascar.xdev.ro jumper hosts
18
-192.168.2.102	192.168.2.102	mazeri.madagascar.xdev.ro mazeri
19
-192.168.10.93	192.168.10.93	tapia.madagascar.xdev.ro pmx.tapia.madagascar.xdev.ro tapia pmx.tapia
20
-192.168.2.103	192.168.2.103	toltec.madagascar.xdev.ro toltec
21
-192.168.2.107	192.168.2.107	zabbix.madagascar.xdev.ro zabbix
11
+192.168.2.96	andrafiabe.madagascar.xdev.ro pbs.andrafiabe.madagascar.xdev.ro andrafiabe pbs.andrafiabe
12
+192.168.2.95	anjothibe.madagascar.xdev.ro pbs.anjothibe.madagascar.xdev.ro anjothibe pbs.anjothibe
13
+192.168.10.21	autonas01.madagascar.xdev.ro autonas01
14
+192.168.10.22	autonas02.madagascar.xdev.ro autonas02
15
+192.168.10.91	baobab.madagascar.xdev.ro pmx.baobab.madagascar.xdev.ro baobab pmx.baobab
16
+192.168.10.92	ebony.madagascar.xdev.ro pmx.ebony.madagascar.xdev.ro ebony pmx.ebony
17
+192.168.2.100	jumper.madagascar.xdev.ro hosts.madagascar.xdev.ro jumper hosts
18
+192.168.2.102	mazeri.madagascar.xdev.ro mazeri
19
+192.168.10.93	tapia.madagascar.xdev.ro pmx.tapia.madagascar.xdev.ro tapia pmx.tapia
20
+192.168.2.103	toltec.madagascar.xdev.ro toltec
21
+192.168.2.107	zabbix.madagascar.xdev.ro zabbix
+379 -102
scripts/host_manager.pl
@@ -224,7 +224,7 @@ sub handle_client {
224 224
 
225 225
 sub app_page_path {
226 226
     my ($path) = @_;
227
-    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca|debug)\z};
227
+    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
228 228
 }
229 229
 
230 230
 sub load_registry {
@@ -363,9 +363,11 @@ sub apply_work_order {
363 363
             my $removed = 0;
364 364
             for my $host (@{ $registry->{hosts} || [] }) {
365 365
                 next unless ($host->{id} || '') eq $host_id;
366
-                my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
367
-                $removed = @kept != @{ $host->{names} || [] };
368
-                $host->{names} = \@kept;
366
+                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
367
+                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
368
+                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
369
+                $host->{aliases} = \@kept_aliases;
370
+                $host->{vhosts} = \@kept_vhosts;
369 371
                 last;
370 372
             }
371 373
             push @results, {
@@ -385,6 +387,7 @@ sub registry_payload {
385 387
     my ($registry) = @_;
386 388
     my $problems = analyze_hosts($registry->{hosts});
387 389
     my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
390
+    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
388 391
     return {
389 392
         version => $registry->{version},
390 393
         updated_at => $registry->{updated_at},
@@ -393,6 +396,7 @@ sub registry_payload {
393 396
         problems => $problems,
394 397
         counts => {
395 398
             hosts => scalar @{ $registry->{hosts} },
399
+            vhosts => $vhost_count,
396 400
             problems => scalar @$problems,
397 401
         },
398 402
     };
@@ -403,36 +407,47 @@ sub upsert_host {
403 407
     my $id = clean_id($payload->{id} || '');
404 408
     return send_json($client, 400, { error => 'invalid_id' }) unless $id;
405 409
 
406
-    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
407
-    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
408
-    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
410
+    my $ip = canonical_ip($payload);
411
+    return send_json($client, 400, { error => 'missing_ip' }) unless $ip;
409 412
 
410
-    my @names = remove_derived_names(clean_list($payload->{names}));
411
-    return send_json($client, 400, { error => 'missing_names' }) unless @names;
413
+    my $fqdn = canonical_host_fqdn($payload);
414
+    return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
415
+    my @aliases = clean_alias_names($payload);
416
+    my @vhosts = clean_vhost_names($payload);
412 417
 
413 418
     my $registry = load_registry();
414 419
     my %host = (
415 420
         id => $id,
421
+        fqdn => $fqdn,
416 422
         status => clean_scalar($payload->{status} || 'active'),
417
-        hosts_ip => $hosts_ip,
418
-        dns_ip => $dns_ip,
419
-        names => \@names,
423
+        ip => $ip,
424
+        aliases => \@aliases,
425
+        vhosts => \@vhosts,
420 426
         roles => [ clean_list($payload->{roles}) ],
421 427
         sources => [ clean_list($payload->{sources}) ],
422 428
         monitoring => clean_scalar($payload->{monitoring} || 'pending'),
423 429
         notes => clean_scalar($payload->{notes} || ''),
424 430
     );
425 431
 
426
-    my $replaced = 0;
427
-    for my $i (0 .. $#{ $registry->{hosts} }) {
428
-        if ($registry->{hosts}->[$i]{id} eq $id) {
429
-            $registry->{hosts}->[$i] = \%host;
430
-            $replaced = 1;
431
-            last;
432
+    my $response = eval {
433
+        my $replaced = 0;
434
+        for my $i (0 .. $#{ $registry->{hosts} }) {
435
+            if ($registry->{hosts}->[$i]{id} eq $id) {
436
+                $registry->{hosts}->[$i] = \%host;
437
+                $replaced = 1;
438
+                last;
439
+            }
432 440
         }
441
+        push @{ $registry->{hosts} }, \%host unless $replaced;
442
+        save_registry($registry);
443
+        1;
444
+    };
445
+    if (!$response) {
446
+        my $err = $@ || 'upsert_failed';
447
+        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
448
+            if $err =~ /alias_conflict:/;
449
+        die $err;
433 450
     }
434
-    push @{ $registry->{hosts} }, \%host unless $replaced;
435
-    save_registry($registry);
436 451
     return send_json($client, 200, { ok => json_bool(1), host => \%host });
437 452
 }
438 453
 
@@ -455,23 +470,23 @@ sub analyze_hosts {
455 470
     my (%names, %ids);
456 471
     for my $host (@$hosts) {
457 472
         push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
458
-        my @fqdn = grep { /\.madagascar\.xdev\.ro$/ } @{ $host->{names} || [] };
459
-        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless @fqdn || ($host->{status} || '') ne 'active';
473
+        my $fqdn = canonical_host_fqdn($host);
474
+        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
475
+        my @declared = declared_dns_names($host);
460 476
         push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
461
-            if grep { /\.vad\.is\.xdev\.ro$/ } @{ $host->{names} || [] };
477
+            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
462 478
         push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
463
-            if grep { /^(is|vad|b)-/ } @{ $host->{names} || [] };
464
-        for my $name (@{ $host->{names} || [] }) {
479
+            if grep { /^(is|vad|b)-/ } @declared;
480
+        for my $name (@declared) {
465 481
             push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
466 482
         }
467
-        my %declared = map { $_ => 1 } @{ $host->{names} || [] };
468
-        for my $derived (derived_names($host)) {
483
+        my %declared = map { $_ => 1 } @declared;
484
+        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
469 485
             push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
470 486
                 if $declared{$derived};
471 487
         }
472
-        if (($host->{hosts_ip} || '') ne ($host->{dns_ip} || '') && ($host->{hosts_ip} || '') ne '127.0.0.1') {
473
-            push @problems, problem($host, 'split-ip', 'hosts_ip differs from dns_ip; check that this is intentional');
474
-        }
488
+        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
489
+            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
475 490
     }
476 491
     return \@problems;
477 492
 }
@@ -479,27 +494,119 @@ sub analyze_hosts {
479 494
 sub host_payload {
480 495
     my ($host) = @_;
481 496
     my %copy = %$host;
497
+    $copy{fqdn} = canonical_host_fqdn($host);
498
+    $copy{ip} = canonical_ip($host);
482 499
     $copy{names} = [ effective_names($host) ];
483
-    $copy{declared_names} = [ @{ $host->{names} || [] } ];
484
-    $copy{derived_names} = [ derived_names($host) ];
500
+    $copy{declared_names} = [ declared_dns_names($host) ];
501
+    $copy{aliases} = [ declared_alias_names($host) ];
502
+    $copy{derived_aliases} = [ derived_alias_names($host) ];
503
+    $copy{vhosts} = [ declared_vhost_names($host) ];
504
+    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
485 505
     return \%copy;
486 506
 }
487 507
 
488 508
 sub effective_names {
489 509
     my ($host) = @_;
490
-    my @names = @{ $host->{names} || [] };
491
-    push @names, derived_names($host);
510
+    my @names = declared_dns_names($host);
511
+    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
492 512
     return unique_preserve(@names);
493 513
 }
494 514
 
495
-sub derived_names {
515
+sub declared_dns_names {
516
+    my ($host) = @_;
517
+    my @names;
518
+    my $fqdn = canonical_host_fqdn($host);
519
+    push @names, $fqdn if length $fqdn;
520
+    push @names, declared_alias_names($host);
521
+    push @names, declared_vhost_names($host);
522
+    return unique_preserve(@names);
523
+}
524
+
525
+sub declared_alias_names {
526
+    my ($host) = @_;
527
+    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
528
+}
529
+
530
+sub declared_vhost_names {
531
+    my ($host) = @_;
532
+    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
533
+}
534
+
535
+sub declared_dns_names_legacy {
536
+    my ($host) = @_;
537
+    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
538
+}
539
+
540
+sub split_legacy_names {
541
+    my ($id, $names) = @_;
542
+    my $fallback = clean_id($id || '');
543
+    my (%result) = (
544
+        fqdn => '',
545
+        aliases => [],
546
+        vhosts => [],
547
+    );
548
+    for my $name (map { normalize_dns_name($_) } @$names) {
549
+        next unless length $name;
550
+        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
551
+            $result{fqdn} = $name;
552
+            next;
553
+        }
554
+        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
555
+            $result{fqdn} = $name;
556
+            next;
557
+        }
558
+        if (name_is_vhost($name)) {
559
+            push @{ $result{vhosts} }, $name;
560
+        } else {
561
+            push @{ $result{aliases} }, $name;
562
+        }
563
+    }
564
+    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
565
+    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
566
+    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
567
+    return \%result;
568
+}
569
+
570
+sub derived_alias_names {
496 571
     my ($host) = @_;
497 572
     my @derived;
498
-    for my $name (@{ $host->{names} || [] }) {
499
-        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
500
-        push @derived, $1 if length $1;
573
+    my $fqdn = canonical_host_fqdn($host);
574
+    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
575
+    for my $name (declared_alias_names($host)) {
576
+        push @derived, short_alias_for_fqdn($name);
577
+    }
578
+    return unique_preserve(grep { length $_ } @derived);
579
+}
580
+
581
+sub derived_vhost_alias_names {
582
+    my ($host) = @_;
583
+    my @derived;
584
+    for my $name (declared_vhost_names($host)) {
585
+        push @derived, short_alias_for_fqdn($name);
501 586
     }
502
-    return unique_preserve(@derived);
587
+    return unique_preserve(grep { length $_ } @derived);
588
+}
589
+
590
+sub clean_alias_names {
591
+    my ($payload) = @_;
592
+    return clean_name_bucket($payload->{aliases})
593
+        if defined $payload->{aliases};
594
+    my @legacy = remove_derived_names(clean_list($payload->{names}));
595
+    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
596
+}
597
+
598
+sub clean_vhost_names {
599
+    my ($payload) = @_;
600
+    return clean_name_bucket($payload->{vhosts})
601
+        if defined $payload->{vhosts};
602
+    my @legacy = remove_derived_names(clean_list($payload->{names}));
603
+    return grep { name_is_vhost($_) } @legacy;
604
+}
605
+
606
+sub clean_name_bucket {
607
+    my ($value) = @_;
608
+    my @names = clean_list($value);
609
+    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
503 610
 }
504 611
 
505 612
 sub remove_derived_names {
@@ -518,6 +625,16 @@ sub unique_preserve {
518 625
     return grep { !$seen{$_}++ } @values;
519 626
 }
520 627
 
628
+sub canonical_ip {
629
+    my ($host) = @_;
630
+    return '' unless $host && ref($host) eq 'HASH';
631
+    for my $key (qw(ip dns_ip hosts_ip)) {
632
+        my $value = clean_scalar($host->{$key} || '');
633
+        return $value if length $value;
634
+    }
635
+    return '';
636
+}
637
+
521 638
 sub problem {
522 639
     my ($host, $code, $message) = @_;
523 640
     return { host_id => $host->{id}, code => $code, message => $message };
@@ -529,7 +646,7 @@ sub render_local_hosts_tsv {
529 646
     $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
530 647
     $out .= "#\n";
531 648
     $out .= "# Format:\n";
532
-    $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
649
+    $out .= "# ip<TAB>name [aliases...]\n";
533 650
     $out .= "#\n";
534 651
     $out .= "# Priority rule:\n";
535 652
     $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
@@ -537,9 +654,11 @@ sub render_local_hosts_tsv {
537 654
     $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
538 655
     for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
539 656
         next unless ($host->{status} || 'active') eq 'active';
657
+        my $ip = canonical_ip($host);
658
+        next unless $ip;
540 659
         my @names = effective_names($host);
541 660
         next unless @names;
542
-        $out .= join("\t", $host->{hosts_ip}, $host->{dns_ip}, join(' ', @names)) . "\n";
661
+        $out .= join("\t", $ip, join(' ', @names)) . "\n";
543 662
     }
544 663
     return $out;
545 664
 }
@@ -554,10 +673,14 @@ sub render_monitoring {
554 673
         push @hosts, {
555 674
             id => $host->{id},
556 675
             primary_name => $names[0],
557
-            address => $host->{dns_ip},
676
+            address => canonical_ip($host),
558 677
             aliases => \@names,
559
-            declared_names => [ @{ $host->{names} || [] } ],
560
-            derived_names => [ derived_names($host) ],
678
+            fqdn => canonical_host_fqdn($host),
679
+            declared_names => [ declared_dns_names($host) ],
680
+            aliases_declared => [ declared_alias_names($host) ],
681
+            aliases_derived => [ derived_alias_names($host) ],
682
+            vhosts_declared => [ declared_vhost_names($host) ],
683
+            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
561 684
             roles => [ @{ $host->{roles} || [] } ],
562 685
             monitoring => $host->{monitoring} || 'pending',
563 686
             notes => $host->{notes} || '',
@@ -830,10 +953,11 @@ sub parse_hosts_yaml {
830 953
         } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
831 954
             $current = {
832 955
                 id => yaml_unquote($1),
956
+                fqdn => '',
833 957
                 status => 'active',
834
-                hosts_ip => '',
835
-                dns_ip => '',
836
-                names => [],
958
+                ip => '',
959
+                aliases => [],
960
+                vhosts => [],
837 961
                 roles => [],
838 962
                 sources => [],
839 963
                 monitoring => 'pending',
@@ -847,10 +971,33 @@ sub parse_hosts_yaml {
847 971
         } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
848 972
             push @{ $current->{$list_key} }, yaml_unquote($1);
849 973
         } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
850
-            $current->{$1} = yaml_unquote($2);
974
+            my $key = $1;
975
+            my $value = yaml_unquote($2);
976
+            if ($key eq 'ip') {
977
+                $current->{ip} = $value;
978
+            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
979
+                $current->{ip} ||= $value;
980
+            } elsif ($key eq 'fqdn') {
981
+                $current->{fqdn} = normalize_dns_name($value);
982
+            } elsif ($key eq 'names') {
983
+                # ignored here; legacy list is handled after parsing
984
+            } else {
985
+                $current->{$key} = $value;
986
+            }
851 987
             $list_key = undef;
852 988
         }
853 989
     }
990
+    for my $host (@{ $registry{hosts} }) {
991
+        my @legacy_names = @{ $host->{names} || [] };
992
+        if (@legacy_names) {
993
+            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
994
+            $host->{fqdn} ||= $legacy->{fqdn};
995
+            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
996
+            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
997
+        }
998
+        delete $host->{names};
999
+        $host->{fqdn} ||= canonical_host_fqdn($host);
1000
+    }
854 1001
     return \%registry;
855 1002
 }
856 1003
 
@@ -865,10 +1012,10 @@ sub render_hosts_yaml {
865 1012
     $out .= "hosts:\n";
866 1013
     for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
867 1014
         $out .= "  - id: " . yq($host->{id}) . "\n";
868
-        for my $key (qw(status hosts_ip dns_ip)) {
869
-            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
870
-        }
871
-        for my $key (qw(names roles sources)) {
1015
+        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1016
+        $out .= "    status: " . yq($host->{status} || '') . "\n";
1017
+        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1018
+        for my $key (qw(aliases vhosts roles sources)) {
872 1019
             $out .= "    $key:\n";
873 1020
             for my $value (@{ $host->{$key} || [] }) {
874 1021
                 $out .= "      - " . yq($value) . "\n";
@@ -1698,10 +1845,11 @@ sub load_registry_from_db {
1698 1845
         my $fqdn = $row->{fqdn};
1699 1846
         push @{ $registry->{hosts} }, {
1700 1847
             id => $row->{legacy_id},
1848
+            fqdn => $fqdn,
1701 1849
             status => $row->{status},
1702
-            hosts_ip => $row->{hosts_ip},
1703
-            dns_ip => $row->{dns_ip},
1704
-            names => [ active_names_for_host($dbh, $fqdn) ],
1850
+            ip => canonical_ip($row),
1851
+            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
1852
+            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
1705 1853
             roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1706 1854
             sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1707 1855
             monitoring => $row->{monitoring},
@@ -1745,8 +1893,7 @@ sub upsert_host_to_db {
1745 1893
     return '' unless $fqdn;
1746 1894
     my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1747 1895
     my $status = clean_scalar($host->{status} || 'active');
1748
-    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1749
-    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1896
+    my $ip = canonical_ip($host);
1750 1897
     my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1751 1898
     my $notes = clean_scalar($host->{notes} || '');
1752 1899
 
@@ -1757,12 +1904,12 @@ sub upsert_host_to_db {
1757 1904
         . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1758 1905
         . 'notes = excluded.notes, updated_at = excluded.updated_at',
1759 1906
         undef,
1760
-        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1907
+        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
1761 1908
     );
1762 1909
 
1763 1910
     sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1764 1911
     sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1765
-    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1912
+    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
1766 1913
     return $fqdn;
1767 1914
 }
1768 1915
 
@@ -1787,32 +1934,33 @@ sub sync_host_values {
1787 1934
     }
1788 1935
 }
1789 1936
 
1790
-sub sync_host_names {
1791
-    my ($dbh, $fqdn, $names) = @_;
1937
+sub sync_host_aliases_and_vhosts {
1938
+    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
1792 1939
     my $now = iso_now();
1793 1940
     my (%aliases, %vhosts);
1794 1941
     if (my $short = short_alias_for_fqdn($fqdn)) {
1795 1942
         $aliases{$short} = 1;
1796 1943
         upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1797 1944
     }
1798
-    for my $name (@$names) {
1945
+    for my $name (@$aliases_in) {
1799 1946
         $name = normalize_dns_name($name);
1800 1947
         next unless length $name;
1801 1948
         next if $name eq $fqdn;
1802
-        if (name_is_vhost($name)) {
1803
-            $vhosts{$name} = 1;
1804
-            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1805
-            if (my $short = short_alias_for_fqdn($name)) {
1806
-                $aliases{$short} = 1;
1807
-                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1808
-            }
1809
-        } else {
1810
-            $aliases{$name} = 1;
1811
-            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1812
-            if (my $short = short_alias_for_fqdn($name)) {
1813
-                $aliases{$short} = 1;
1814
-                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1815
-            }
1949
+        $aliases{$name} = 1;
1950
+        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1951
+        if (my $short = short_alias_for_fqdn($name)) {
1952
+            $aliases{$short} = 1;
1953
+            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1954
+        }
1955
+    }
1956
+    for my $name (@$vhosts_in) {
1957
+        $name = normalize_dns_name($name);
1958
+        next unless length $name;
1959
+        $vhosts{$name} = 1;
1960
+        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1961
+        if (my $short = short_alias_for_fqdn($name)) {
1962
+            $aliases{$short} = 1;
1963
+            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1816 1964
         }
1817 1965
     }
1818 1966
 
@@ -1822,6 +1970,22 @@ sub sync_host_names {
1822 1970
 
1823 1971
 sub upsert_alias_to_db {
1824 1972
     my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1973
+    my ($existing_fqdn) = $dbh->selectrow_array(
1974
+        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
1975
+        undef,
1976
+        $alias,
1977
+    );
1978
+    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
1979
+        if ($kind eq 'derived-vhost') {
1980
+            $dbh->do(
1981
+                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
1982
+                undef,
1983
+                $now, $alias, $existing_fqdn,
1984
+            );
1985
+        } else {
1986
+            die "alias_conflict: $alias is already active on $existing_fqdn\n";
1987
+        }
1988
+    }
1825 1989
     $dbh->do(
1826 1990
         'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1827 1991
         . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
@@ -1874,14 +2038,20 @@ sub retire_host_in_db {
1874 2038
     $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1875 2039
 }
1876 2040
 
1877
-sub active_names_for_host {
2041
+sub active_aliases_for_host {
1878 2042
     my ($dbh, $fqdn) = @_;
1879
-    my @names = ($fqdn);
2043
+    my @names;
1880 2044
     my $aliases = $dbh->prepare("SELECT alias_name FROM host_aliases WHERE host_fqdn = ? AND status = 'active' AND is_dns_published = 1 AND alias_kind NOT LIKE 'derived%' ORDER BY alias_name");
1881 2045
     $aliases->execute($fqdn);
1882 2046
     while (my ($name) = $aliases->fetchrow_array) {
1883 2047
         push @names, $name;
1884 2048
     }
2049
+    return unique_preserve(@names);
2050
+}
2051
+
2052
+sub active_vhosts_for_host {
2053
+    my ($dbh, $fqdn) = @_;
2054
+    my @names;
1885 2055
     my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1886 2056
     $vhosts->execute($fqdn);
1887 2057
     while (my ($name) = $vhosts->fetchrow_array) {
@@ -2099,16 +2269,15 @@ sub fqdn_for_legacy_id {
2099 2269
 
2100 2270
 sub canonical_host_fqdn {
2101 2271
     my ($host) = @_;
2102
-    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
2272
+    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2273
+    return $fqdn if length $fqdn;
2274
+    my @names = declared_dns_names_legacy($host);
2103 2275
     for my $name (@names) {
2104 2276
         return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2105 2277
     }
2106 2278
     for my $name (@names) {
2107 2279
         return $name if $name =~ /\./ && !name_is_vhost($name);
2108 2280
     }
2109
-    for my $name (@names) {
2110
-        return $name if $name =~ /\./;
2111
-    }
2112 2281
     my $id = clean_id($host->{id} || '');
2113 2282
     return $id ? "$id.madagascar.xdev.ro" : '';
2114 2283
 }
@@ -2462,6 +2631,8 @@ sub app_html {
2462 2631
     .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2463 2632
     .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
2464 2633
     .pill.derived { border-style: dashed; }
2634
+    .pill.canonical { font-weight: 700; }
2635
+    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
2465 2636
     .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2466 2637
     .span2 { grid-column: 1 / -1; }
2467 2638
     label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
@@ -2637,6 +2808,7 @@ sub app_html {
2637 2808
       <nav aria-label="Sections">
2638 2809
         <a href="/overview" data-page-link="overview">Overview</a>
2639 2810
         <a href="/hosts" data-page-link="hosts">Hosts</a>
2811
+        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
2640 2812
         <a href="/dns" data-page-link="dns">DNS</a>
2641 2813
         <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2642 2814
         <a href="/ca" data-page-link="ca">Local CA</a>
@@ -2674,8 +2846,7 @@ sub app_html {
2674 2846
               <thead>
2675 2847
                 <tr>
2676 2848
                   <th style="width: 120px">ID</th>
2677
-                  <th style="width: 130px">hosts_ip</th>
2678
-                  <th style="width: 130px">dns_ip</th>
2849
+                  <th style="width: 140px">IP</th>
2679 2850
                   <th>Names</th>
2680 2851
                   <th style="width: 150px">Roles</th>
2681 2852
                   <th style="width: 110px">Monitoring</th>
@@ -2688,6 +2859,33 @@ sub app_html {
2688 2859
         </section>
2689 2860
       </section>
2690 2861
 
2862
+      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
2863
+        <section class="panel">
2864
+          <div class="panel-head">
2865
+            <h2>Vhosts</h2>
2866
+            <div class="host-tools">
2867
+              <input id="vhost-filter" placeholder="filter">
2868
+              <div class="stats" id="vhost-stats"></div>
2869
+            </div>
2870
+          </div>
2871
+          <div class="table-wrap">
2872
+            <table>
2873
+              <thead>
2874
+                <tr>
2875
+                  <th>Vhost</th>
2876
+                  <th style="width: 190px">Host</th>
2877
+                  <th style="width: 140px">IP</th>
2878
+                  <th style="width: 180px">Derived aliases</th>
2879
+                  <th style="width: 120px">Monitoring</th>
2880
+                  <th style="width: 90px">Status</th>
2881
+                </tr>
2882
+              </thead>
2883
+              <tbody id="vhosts"></tbody>
2884
+            </table>
2885
+          </div>
2886
+        </section>
2887
+      </section>
2888
+
2691 2889
       <section class="page" id="page-dns" data-page="dns" hidden>
2692 2890
         <section class="toolbar">
2693 2891
           <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
@@ -2796,10 +2994,11 @@ sub app_html {
2796 2994
         </div>
2797 2995
         <form id="host-form" class="grid">
2798 2996
           <label>ID<input name="id" required></label>
2997
+          <label>FQDN<input name="fqdn" required></label>
2799 2998
           <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2800
-          <label>hosts_ip<input name="hosts_ip" required></label>
2801
-          <label>dns_ip<input name="dns_ip" required></label>
2802
-          <label class="span2">Names<textarea name="names" required></textarea></label>
2999
+          <label>IP<input name="ip" required></label>
3000
+          <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3001
+          <label class="span2">Vhosts<textarea name="vhosts"></textarea></label>
2803 3002
           <label>Roles<input name="roles"></label>
2804 3003
           <label>Sources<input name="sources"></label>
2805 3004
           <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
@@ -2829,6 +3028,7 @@ sub app_html {
2829 3028
       '/': 'overview',
2830 3029
       '/overview': 'overview',
2831 3030
       '/hosts': 'hosts',
3031
+      '/vhosts': 'vhosts',
2832 3032
       '/dns': 'dns',
2833 3033
       '/work-orders': 'work-orders',
2834 3034
       '/ca': 'ca',
@@ -2851,6 +3051,20 @@ sub app_html {
2851 3051
       showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2852 3052
     }
2853 3053
 
3054
+    async function ensureAuthenticated(message) {
3055
+      if (!state.authenticated) {
3056
+        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3057
+        return false;
3058
+      }
3059
+      const session = await api('/api/session');
3060
+      state.authenticated = session.authenticated;
3061
+      if (!state.authenticated) {
3062
+        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3063
+        return false;
3064
+      }
3065
+      return true;
3066
+    }
3067
+
2854 3068
     async function api(path, options = {}) {
2855 3069
       const res = await fetch(path, options);
2856 3070
       let body = {};
@@ -2932,6 +3146,7 @@ sub app_html {
2932 3146
 
2933 3147
       $('stats').innerHTML = [
2934 3148
         ['hosts', data.counts.hosts],
3149
+        ['vhosts', data.counts.vhosts || vhostRows().length],
2935 3150
         ['problems', data.counts.problems],
2936 3151
       ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2937 3152
 
@@ -2940,6 +3155,7 @@ sub app_html {
2940 3155
         : '<div class="muted" style="padding: 8px 0">No registry problems detected.</div>';
2941 3156
 
2942 3157
       renderHosts();
3158
+      renderVhosts();
2943 3159
     }
2944 3160
 
2945 3161
     async function renderCa() {
@@ -3251,38 +3467,88 @@ sub app_html {
3251 3467
           const cls = problems.length ? 'warn' : 'ok';
3252 3468
           return `<tr data-id="${escapeHtml(h.id)}">
3253 3469
             <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3254
-            <td>${escapeHtml(h.hosts_ip || '')}</td>
3255
-            <td>${escapeHtml(h.dns_ip || '')}</td>
3470
+            <td>${escapeHtml(h.ip || '')}</td>
3256 3471
             <td>${renderNamePills(h)}</td>
3257 3472
             <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3258 3473
             <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3259 3474
             <td>${escapeHtml(h.status || '')}</td>
3260 3475
           </tr>`;
3261 3476
         }).join('');
3262
-      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
3477
+      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3478
+        editHost(button.dataset.edit).catch(e => {
3479
+          if (!isAuthLost(e)) msg(e.message);
3480
+        });
3481
+      }));
3263 3482
     }
3264 3483
 
3265 3484
     function renderNamePills(host) {
3266
-      const declared = host.declared_names || host.names || [];
3267
-      const derived = host.derived_names || [];
3268
-      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3269
-      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
3270
-      return declaredHtml + derivedHtml;
3485
+      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3486
+      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3487
+      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
3488
+      const vhosts = (host.vhosts || []).map(name => `<span class="pill vhost">${escapeHtml(name)}</span>`).join('');
3489
+      const derivedVhostAliases = (host.derived_vhost_aliases || []).map(name => `<span class="pill derived vhost" title="derived vhost alias">${escapeHtml(name)}</span>`).join('');
3490
+      return canonical + aliases + derivedAliases + vhosts + derivedVhostAliases;
3491
+    }
3492
+
3493
+    function vhostRows() {
3494
+      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3495
+        vhost,
3496
+        host_id: host.id || '',
3497
+        host_fqdn: host.fqdn || '',
3498
+        ip: host.ip || '',
3499
+        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3500
+        monitoring: host.monitoring || '',
3501
+        status: host.status || '',
3502
+      })));
3503
+    }
3504
+
3505
+    function renderVhosts() {
3506
+      const input = $('vhost-filter');
3507
+      const filter = input ? input.value.toLowerCase() : '';
3508
+      const rows = vhostRows()
3509
+        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3510
+        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3511
+      $('vhost-stats').innerHTML = [
3512
+        ['shown', rows.length],
3513
+        ['total', vhostRows().length],
3514
+      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3515
+      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3516
+        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
3517
+        <td><button type="button" data-edit-vhost-host="${escapeHtml(row.host_id)}">${escapeHtml(row.host_id)}</button><div class="muted mono">${escapeHtml(row.host_fqdn)}</div></td>
3518
+        <td>${escapeHtml(row.ip)}</td>
3519
+        <td>${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</td>
3520
+        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3521
+        <td>${escapeHtml(row.status)}</td>
3522
+      </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
3523
+      document.querySelectorAll('[data-edit-vhost-host]').forEach(button => button.addEventListener('click', () => {
3524
+        editHost(button.dataset.editVhostHost).catch(e => {
3525
+          if (!isAuthLost(e)) msg(e.message);
3526
+        });
3527
+      }));
3528
+    }
3529
+
3530
+    function shortAliasForFqdn(name) {
3531
+      const suffix = '.madagascar.xdev.ro';
3532
+      name = String(name || '').toLowerCase();
3533
+      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
3271 3534
     }
3272 3535
 
3273
-    function editHost(id) {
3536
+    async function editHost(id) {
3537
+      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
3274 3538
       const host = state.hosts.find(h => h.id === id);
3275 3539
       if (!host) return;
3276 3540
       const form = $('host-form');
3277 3541
       clearHostFormMessage();
3278
-      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3279
-      hostField('names').value = (host.declared_names || host.names || []).join('\n');
3542
+      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3543
+      hostField('aliases').value = (host.aliases || []).join('\n');
3544
+      hostField('vhosts').value = (host.vhosts || []).join('\n');
3280 3545
       hostField('roles').value = (host.roles || []).join(' ');
3281 3546
       hostField('sources').value = (host.sources || []).join(' ');
3282 3547
       openHostModal('Edit host');
3283 3548
     }
3284 3549
 
3285
-    function newHost() {
3550
+    async function newHost() {
3551
+      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
3286 3552
       const form = $('host-form');
3287 3553
       form.reset();
3288 3554
       clearHostFormMessage();
@@ -3461,13 +3727,18 @@ sub app_html {
3461 3727
     else otpDigits[0].focus();
3462 3728
 
3463 3729
     document.querySelectorAll('[data-page-link]').forEach(link => {
3464
-      link.addEventListener('click', (event) => {
3730
+      link.addEventListener('click', async (event) => {
3465 3731
         event.preventDefault();
3732
+        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
3466 3733
         showPage(link.dataset.pageLink, true);
3467 3734
       });
3468 3735
     });
3469 3736
 
3470
-    window.addEventListener('popstate', () => showPage(currentPage()));
3737
+    window.addEventListener('popstate', () => {
3738
+      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3739
+        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3740
+        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3741
+    });
3471 3742
 
3472 3743
     async function copyText(text) {
3473 3744
       if (navigator.clipboard && window.isSecureContext) {
@@ -3518,7 +3789,12 @@ sub app_html {
3518 3789
       if (!isAuthLost(e)) msg(e.message);
3519 3790
     }));
3520 3791
     $('filter').addEventListener('input', renderHosts);
3521
-    $('new-host').addEventListener('click', newHost);
3792
+    $('vhost-filter').addEventListener('input', renderVhosts);
3793
+    $('new-host').addEventListener('click', () => {
3794
+      newHost().catch(e => {
3795
+        if (!isAuthLost(e)) msg(e.message);
3796
+      });
3797
+    });
3522 3798
     $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3523 3799
       if (!isAuthLost(e)) msg(e.message);
3524 3800
     }));
@@ -3532,6 +3808,7 @@ sub app_html {
3532 3808
 
3533 3809
     $('host-form').addEventListener('submit', async (event) => {
3534 3810
       event.preventDefault();
3811
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
3535 3812
       setHostFormBusy(true);
3536 3813
       setHostFormMessage('Saving...');
3537 3814
       try {
+14 -6
scripts/sync_local_hosts.sh
@@ -96,20 +96,28 @@ while IFS= read -r line || [[ -n "$line" ]]; do
96 96
     [[ -z "${line//[[:space:]]/}" ]] && continue
97 97
     [[ "$line" =~ ^[[:space:]]*# ]] && continue
98 98
 
99
-    read -r hosts_ip dns_ip names <<< "$line"
100
-    [[ -n "${hosts_ip:-}" && -n "${dns_ip:-}" && -n "${names:-}" ]] || die "Invalid row: $line"
99
+    line="${line//$'\r'/}"
100
+    IFS=$'\t' read -r col1 col2 col3 _ <<< "$line"
101
+    if [[ -n "${col3:-}" ]]; then
102
+        ip="$col2"
103
+        names="$col3"
104
+    else
105
+        ip="$col1"
106
+        names="$col2"
107
+    fi
108
+    [[ -n "${ip:-}" && -n "${names:-}" ]] || die "Invalid row: $line"
101 109
 
102
-    printf '%s   %s\n' "$hosts_ip" "$names" >> "$HOSTS_ROWS"
110
+    printf '%s   %s\n' "$ip" "$names" >> "$HOSTS_ROWS"
103 111
 
104 112
     for name in $names; do
105 113
         printf '%s\n' "$name" >> "$NAMES_FILE"
106
-        printf '%-40s %s\n' "$name" "$dns_ip" >> "$CLOAK_ROWS"
114
+        printf '%-40s %s\n' "$name" "$ip" >> "$CLOAK_ROWS"
107 115
         if [[ "$name" == *.xdev.ro ]]; then
108
-            printf '%s %s\n' "$name" "$dns_ip" >> "$VERIFY_ROWS"
116
+            printf '%s %s\n' "$name" "$ip" >> "$VERIFY_ROWS"
109 117
         fi
110 118
 
111 119
         ros_name="$(quote_ros "$name")"
112
-        ros_ip="$(quote_ros "$dns_ip")"
120
+        ros_ip="$(quote_ros "$ip")"
113 121
         {
114 122
             printf '/ip dns static remove [find name="%s"]\n' "$ros_name"
115 123
             printf '/ip dns static add name="%s" type=A address=%s comment="xdev-local managed"\n' "$ros_name" "$ros_ip"