Showing 18 changed files with 1147 additions and 258 deletions
+11 -6
.doc/database/README.md
@@ -1,6 +1,6 @@
1 1
 # SQLite Database
2 2
 
3
-Madagascar Local Authority folosește SQLite ca sursă de adevăr runtime pentru hosturi, aliasuri, vhosturi, Work Orders, workeri de date și certificate.
3
+Madagascar Local Authority folosește SQLite ca sursă de adevăr runtime pentru hosturi, aliasuri, vhosturi, taguri, Work Orders, workeri de date și certificate.
4 4
 
5 5
 Locația implicită în checkout:
6 6
 
@@ -22,7 +22,7 @@ HOST_MANAGER_DB=/path/to/host-manager.sqlite
22 22
 
23 23
 ## Principii
24 24
 
25
-Hosturile sunt identificate prin FQDN complet, nu prin short name. Exemplu: `gw.local` și `gw.remote` sunt identități diferite. Coloana compatibilă `legacy_id` păstrează ID-ul scurt folosit de UI-ul curent, dar cheia reală este `hosts.fqdn`.
25
+Hosturile sunt identificate prin FQDN complet, nu prin short name. Exemplu: `gw.local` și `gw.remote` sunt identități diferite. Coloana internă compatibilă `legacy_id` păstrează ID-ul scurt pentru migrare și Work Orders istorice, dar contractul UI/export este `hosts.fqdn`.
26 26
 
27 27
 Schema evită să transforme `hosts` într-un tabel cu prea multe coloane. Datele specializate stau în tabele separate:
28 28
 
@@ -30,6 +30,7 @@ Schema evită să transforme `hosts` într-un tabel cu prea multe coloane. Datel
30 30
 - roluri: [`host_roles`](tables/host_roles.md)
31 31
 - surse: [`host_sources`](tables/host_sources.md)
32 32
 - flaguri: [`host_flags`](tables/host_flags.md)
33
+- taguri: [`tags`](tables/tags.md), [`host_tags`](tables/host_tags.md)
33 34
 - SSH: [`host_ssh`](tables/host_ssh.md)
34 35
 - vhosturi mutabile: [`vhosts`](tables/vhosts.md)
35 36
 - certificate: [`certificates`](tables/certificates.md), [`certificate_dns_names`](tables/certificate_dns_names.md)
@@ -39,10 +40,10 @@ Schema evită să transforme `hosts` într-un tabel cu prea multe coloane. Datel
39 40
 
40 41
 ## Schema Version
41 42
 
42
-Schema curentă este versiunea `2`.
43
+Schema curentă este versiunea `3`.
43 44
 
44 45
 ```sql
45
-schema_meta('schema_version') = '2'
46
+schema_meta('schema_version') = '3'
46 47
 ```
47 48
 
48 49
 [`schema_meta`](tables/schema_meta.md) păstrează și metadate runtime precum `registry_updated_at`.
@@ -58,6 +59,8 @@ schema_meta('schema_version') = '2'
58 59
 | [`host_roles`](tables/host_roles.md) | roluri active/retrase per host |
59 60
 | [`host_sources`](tables/host_sources.md) | surse active/retrase per host |
60 61
 | [`host_flags`](tables/host_flags.md) | flaguri extensibile per host |
62
+| [`tags`](tables/tags.md) | catalog de taguri cu label, culoare și icon |
63
+| [`host_tags`](tables/host_tags.md) | asocieri active/retrase între hosturi și taguri |
61 64
 | [`host_ssh`](tables/host_ssh.md) | profile SSH per host |
62 65
 | [`vhosts`](tables/vhosts.md) | vhosturi mutabile între hosturi |
63 66
 | [`data_workers`](tables/data_workers.md) | workeri/surse care colectează date |
@@ -78,6 +81,8 @@ erDiagram
78 81
   hosts ||--o{ host_roles : has
79 82
   hosts ||--o{ host_sources : has
80 83
   hosts ||--o{ host_flags : has
84
+  hosts ||--o{ host_tags : tagged
85
+  tags ||--o{ host_tags : catalog
81 86
   hosts ||--o{ host_ssh : has
82 87
   hosts ||--o{ dhcp_leases : may_match
83 88
   hosts ||--o{ mdns_observations : may_match
@@ -108,7 +113,7 @@ Seed-ul curent produce:
108 113
 - workerii `dhcp-router` și `mdns-listener`
109 114
 - Work Order-ul existent pentru retragerea numelor legacy
110 115
 
111
-`config/local-hosts.tsv` rămâne manifest generat explicit din tabelele runtime, dar scriptul de sync citește manifestul direct din SQLite-ul runtime de pe jumper.
116
+`config/hosts.yaml` este exportul finit și seed pentru instalări noi. Nu mai există un export versionat `local-hosts.tsv`; scriptul de sync generează recorduri efemere direct din SQLite-ul runtime de pe jumper.
112 117
 
113 118
 ## Inspecție
114 119
 
@@ -122,7 +127,7 @@ sqlite3 var/host-manager.sqlite '.schema host_aliases'
122 127
 sqlite3 var/host-manager.sqlite '.schema vhosts'
123 128
 sqlite3 var/host-manager.sqlite 'pragma foreign_key_list(vhosts);'
124 129
 sqlite3 var/host-manager.sqlite 'pragma index_list(host_aliases);'
125
-sqlite3 var/host-manager.sqlite 'select fqdn, legacy_id, status, dns_ip from hosts order by legacy_id;'
130
+sqlite3 var/host-manager.sqlite 'select fqdn, status, dns_ip from hosts order by fqdn;'
126 131
 sqlite3 var/host-manager.sqlite 'select alias_name, host_fqdn, alias_kind, status from host_aliases order by alias_name;'
127 132
 sqlite3 var/host-manager.sqlite 'select vhost_fqdn, host_fqdn, status from vhosts order by vhost_fqdn;'
128 133
 ```
+1 -1
.doc/database/tables/host_sources.md
@@ -7,7 +7,7 @@ Stores evidence/source labels for a host.
7 7
 | Column | Type | Null | Default | Notes |
8 8
 |--------|------|------|---------|-------|
9 9
 | `host_fqdn` | `TEXT` | no | none | Target host. References `hosts(fqdn)`. |
10
-| `source` | `TEXT` | no | none | Source label, for example `local-hosts.tsv` or `madagascar.json`. |
10
+| `source` | `TEXT` | no | none | Source label kept for historical audit, for example `madagascar.json`. |
11 11
 | `status` | `TEXT` | no | `'active'` | Source lifecycle state. |
12 12
 | `created_at` | `TEXT` | no | none | ISO UTC creation timestamp. |
13 13
 | `retired_at` | `TEXT` | yes | `NULL` | ISO UTC retirement timestamp. |
+43 -0
.doc/database/tables/host_tags.md
@@ -0,0 +1,43 @@
1
+# Table: `host_tags`
2
+
3
+Stores active and retired tag associations for canonical hosts.
4
+
5
+## Columns
6
+
7
+| Column | Type | Null | Default | Notes |
8
+|--------|------|------|---------|-------|
9
+| `host_fqdn` | `TEXT` | no | none | Canonical host FQDN. References `hosts(fqdn)`. |
10
+| `tag_id` | `TEXT` | no | none | Tag identifier. References `tags(tag_id)`. |
11
+| `status` | `TEXT` | no | `'active'` | Association lifecycle state. |
12
+| `created_at` | `TEXT` | no | none | ISO UTC creation timestamp. |
13
+| `retired_at` | `TEXT` | yes | none | ISO UTC retirement timestamp. |
14
+
15
+## Keys And Indexes
16
+
17
+- Primary key: `(host_fqdn, tag_id)`
18
+- Lookup index: `idx_host_tags_tag_status` on `(tag_id, status)`
19
+
20
+## Relationships
21
+
22
+References:
23
+
24
+- `hosts.fqdn`
25
+- `tags.tag_id`
26
+
27
+## Definition
28
+
29
+```sql
30
+CREATE TABLE IF NOT EXISTS host_tags (
31
+    host_fqdn TEXT NOT NULL,
32
+    tag_id TEXT NOT NULL,
33
+    status TEXT NOT NULL DEFAULT 'active',
34
+    created_at TEXT NOT NULL,
35
+    retired_at TEXT,
36
+    PRIMARY KEY (host_fqdn, tag_id),
37
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
38
+    FOREIGN KEY (tag_id) REFERENCES tags(tag_id) ON UPDATE CASCADE ON DELETE RESTRICT
39
+);
40
+
41
+CREATE INDEX IF NOT EXISTS idx_host_tags_tag_status
42
+ON host_tags(tag_id, status);
43
+```
+3 -2
.doc/database/tables/hosts.md
@@ -2,14 +2,14 @@
2 2
 
3 3
 Canonical host registry. Hosts are identified by full DNS name in `fqdn`.
4 4
 
5
-`legacy_id` exists for compatibility with the current UI/API, but it is not the canonical identity.
5
+`legacy_id` and `hosts_ip` exist for compatibility with existing runtime databases and Work Orders, but they are not the product contract. UI and exports identify hosts by `fqdn` and expose one canonical `ip`.
6 6
 
7 7
 ## Columns
8 8
 
9 9
 | Column | Type | Null | Default | Notes |
10 10
 |--------|------|------|---------|-------|
11 11
 | `fqdn` | `TEXT` | no | none | Canonical full host name. Primary key. |
12
-| `legacy_id` | `TEXT` | no | none | Short ID used by the existing UI/API. Unique. |
12
+| `legacy_id` | `TEXT` | no | none | Internal compatibility ID. Unique. |
13 13
 | `status` | `TEXT` | no | `'active'` | Host lifecycle state, currently `active`, `planned`, or `retired`. |
14 14
 | `hosts_ip` | `TEXT` | no | `''` | Legacy compatibility column. The current app model keeps one canonical routable IP and mirrors it here. |
15 15
 | `dns_ip` | `TEXT` | no | `''` | Canonical routable IP for the host. The current UI/API expose this as a single `ip` field. |
@@ -31,6 +31,7 @@ Referenced by:
31 31
 - `host_roles.host_fqdn`
32 32
 - `host_sources.host_fqdn`
33 33
 - `host_flags.host_fqdn`
34
+- `host_tags.host_fqdn`
34 35
 - `host_ssh.host_fqdn`
35 36
 - `vhosts.host_fqdn`
36 37
 - `dhcp_leases.host_fqdn`
+40 -0
.doc/database/tables/tags.md
@@ -0,0 +1,40 @@
1
+# Table: `tags`
2
+
3
+Catalog for host tags managed in the UI.
4
+
5
+## Columns
6
+
7
+| Column | Type | Null | Default | Notes |
8
+|--------|------|------|---------|-------|
9
+| `tag_id` | `TEXT` | no | none | Stable tag identifier derived from the label. Primary key. |
10
+| `label` | `TEXT` | no | none | Display label. Unique. |
11
+| `color` | `TEXT` | no | `'#647084'` | UI color, stored as `#rrggbb`. |
12
+| `icon` | `TEXT` | no | `'tag'` | UI icon key. |
13
+| `notes` | `TEXT` | no | `''` | Operator notes. |
14
+| `created_at` | `TEXT` | no | none | ISO UTC creation timestamp. |
15
+| `updated_at` | `TEXT` | no | none | ISO UTC update timestamp. |
16
+
17
+## Keys And Indexes
18
+
19
+- Primary key: `tag_id`
20
+- Unique key: `label`
21
+
22
+## Relationships
23
+
24
+Referenced by:
25
+
26
+- `host_tags.tag_id`
27
+
28
+## Definition
29
+
30
+```sql
31
+CREATE TABLE IF NOT EXISTS tags (
32
+    tag_id TEXT PRIMARY KEY,
33
+    label TEXT NOT NULL UNIQUE,
34
+    color TEXT NOT NULL DEFAULT '#647084',
35
+    icon TEXT NOT NULL DEFAULT 'tag',
36
+    notes TEXT NOT NULL DEFAULT '',
37
+    created_at TEXT NOT NULL,
38
+    updated_at TEXT NOT NULL
39
+);
40
+```
+3 -0
.doc/development-log.md
@@ -39,3 +39,6 @@ Jurnalele explica deciziile care schimba scopul aplicatiei, regulile de operare,
39 39
 | 2026-06-09 | SQLite Runtime Source of Truth | [Database](development-logs/database.md#2026-06-09---sqlite-runtime-source-of-truth) |
40 40
 | 2026-06-09 | Relational Runtime Schema | [Database](development-logs/database.md#2026-06-09---relational-runtime-schema) |
41 41
 | 2026-06-10 | DHCP Lease Push Collector | [Database](development-logs/database.md#2026-06-10---dhcp-lease-push-collector) |
42
+| 2026-06-11 | Registry Maturity Contract | [Database](development-logs/database.md#2026-06-11---registry-maturity-contract) |
43
+| 2026-06-11 | No Intermediate DNS TSV Product | [DNS](development-logs/dns.md#2026-06-11---no-intermediate-dns-tsv-product) |
44
+| 2026-06-11 | Tags and Observed Hints | [Hosts view](development-logs/hosts-view.md#2026-06-11---tags-and-observed-hints) |
+21 -3
.doc/development-logs/database.md
@@ -11,15 +11,15 @@ Decizie:
11 11
 - `var/host-manager.sqlite` devine sursa de adevar runtime pentru registry si Work Orders
12 12
 - `config/hosts.yaml` si `config/work-orders.yaml` raman seed/snapshot/export compatibility files
13 13
 - la prima pornire, aplicatia seed-uieste documentele lipsa din YAML in SQLite
14
-- download-urile `/download/hosts.yaml`, `/download/local-hosts.tsv` si `/download/monitoring.json` sunt randate din SQLite
15
-- `config/local-hosts.tsv` ramane manifest generat explicit pentru sync-ul DNS local
14
+- download-urile sunt randate din SQLite
15
+- la acel moment exista un manifest TSV explicit pentru sync-ul DNS local; acesta a fost retras prin contractul din 2026-06-11
16 16
 - push-urile de cod catre jumper nu trebuie sa inlocuiasca baza runtime din `var/`
17 17
 
18 18
 Scop:
19 19
 
20 20
 - editarile facute in UI sa nu se piarda la deploy/push de cod
21 21
 - sa existe o singura autoritate runtime
22
-- YAML/TSV sa ramana utile pentru export, review si bootstrap fara sa fie storage-ul live
22
+- YAML/exporturile sa ramana utile pentru review si bootstrap fara sa fie storage-ul live
23 23
 
24 24
 ## 2026-06-09 - Relational Runtime Schema
25 25
 
@@ -57,3 +57,21 @@ Scop:
57 57
 
58 58
 - sa colectam lease-uri fara credential SSH pentru router in aplicatie
59 59
 - sa pastram DHCP ca sursa observata cu prioritate mare, dar fara side effect automat asupra DNS-ului local
60
+
61
+## 2026-06-11 - Registry Maturity Contract
62
+
63
+Observatie: modelul vechi pastra mai multe artefacte care puteau fi confundate cu surse de adevar: `local-hosts.tsv`, campuri legacy din export si surse istorice pe host.
64
+
65
+Decizie:
66
+
67
+- SQLite ramane singura sursa de adevar runtime
68
+- `hosts.yaml` devine produsul finit descarcabil si vizibil raw in UI
69
+- `local-hosts.tsv` nu mai este fisier versionat sau download; resolver sync genereaza recorduri efemere din SQLite
70
+- schema urca la versiunea 3 cu `tags` si `host_tags`
71
+- `dhcp_leases` si `mdns_observations` sunt inputuri observate pentru hinturi/autocomplete, nu muta automat registry-ul
72
+
73
+Motiv:
74
+
75
+- sa evitam drift intre DB, exporturi si configurarea resolverelor
76
+- sa facem tagurile o functie administrabila, nu text liber fara catalog
77
+- sa pastram observatiile externe utile fara sa le promovam automat in DNS
+7 -1
.doc/development-logs/dns.md
@@ -22,7 +22,7 @@ sa urmeze rapid dupa editarea registry-ului.
22 22
 
23 23
 Decizie:
24 24
 
25
-- modificarile DNS facute prin aplicatie regenereaza `config/local-hosts.tsv`
25
+- modificarile DNS facute prin aplicatie nu mai scriu un manifest intermediar
26 26
 - aplicatia atinge `var/dns-publish.trigger`
27 27
 - `host-manager-dns-publish.path` porneste `host-manager-dns-publish.service`
28 28
 - serviciul oneshot ruleaza sync-ul privilegiat existent:
@@ -32,3 +32,9 @@ Decizie:
32 32
 ```
33 33
 
34 34
 Sync-ul manual ramane disponibil pentru interventii operationale si audit.
35
+
36
+## 2026-06-11 - No Intermediate DNS TSV Product
37
+
38
+Decizie: `local-hosts.tsv` nu mai este produs versionat, endpoint download sau fisier scris de aplicatie. `scripts/sync_local_hosts.sh` cere recorduri efemere de la `host_manager.pl --print-resolver-records`, generate direct din SQLite.
39
+
40
+Motiv: produsul finit pentru registry este `hosts.yaml`, iar configurarea resolverelor este o actiune operationala. Un fisier TSV intermediar crea inca un loc care putea fi confundat cu sursa de adevar.
+15 -0
.doc/development-logs/hosts-view.md
@@ -3,3 +3,18 @@
3 3
 Decizii despre UI/API pentru registry, editari de hosturi, aliasuri, vhosturi si experienta de operare in hosts view.
4 4
 
5 5
 Nu exista inca intrari mutate aici din logul istoric.
6
+
7
+## 2026-06-11 - Tags and Observed Hints
8
+
9
+Decizie:
10
+
11
+- UI-ul afiseaza hosturile prin FQDN si nu mai aglomereaza tabelele cu aliasuri derivate de tip short name
12
+- tagurile au catalog administrabil cu redenumire, culoare si icon
13
+- hosturile pot fi asociate cu taguri din editor
14
+- observatiile DHCP si mDNS apar ca hinturi/autocomplete la hosturi si vhosturi
15
+- pentru un nume observat precum `nasturel.local`, UI-ul poate propune IP-ul sau hostul existent cu acel IP, dar nu scrie automat in registry
16
+
17
+Motiv:
18
+
19
+- ecranul principal trebuie sa arate intentia aprobata, nu toate numele derivate
20
+- inputurile observate sunt utile la editare, dar nu sunt sursa de adevar
+20 -11
.doc/host-manager.md
@@ -16,7 +16,7 @@ MVP-ul curent nu are dependențe CPAN instalate direct pe host. Pentru store-ul
16 16
 
17 17
 ## Rol
18 18
 
19
-`var/host-manager.sqlite` este sursa de adevăr runtime pentru hosturi, aliasuri, vhosturi, Work Orders, workeri de date și certificate emise. La prima pornire, aplicația seed-uiește tabelele din `config/hosts.yaml` și `config/work-orders.yaml` dacă baza nu are încă rânduri operaționale. Aplicația este complet în spatele autentificării OTP pentru orice date de registru, exporturi sau modificări.
19
+`var/host-manager.sqlite` este sursa de adevăr runtime pentru hosturi, aliasuri, vhosturi, taguri, Work Orders, workeri de date și certificate emise. `hosts.yaml` este produsul finit exportat din baza runtime și seed pentru baze noi. La prima pornire, aplicația seed-uiește tabelele din `config/hosts.yaml` și `config/work-orders.yaml` dacă baza nu are încă rânduri operaționale. Aplicația este complet în spatele autentificării OTP pentru orice date de registru, exporturi sau modificări.
20 20
 
21 21
 Git rămâne mecanismul pentru cod, seed-uri, exporturi și istoric manual. Aplicația nu mai scrie registry-ul live direct în working tree, ca editările făcute în UI să nu se piardă la push/deploy de cod.
22 22
 
@@ -35,18 +35,22 @@ Healthcheck-ul `/healthz` este disponibil doar pe backend-ul local (`127.0.0.1:8
35 35
 Endpoint-uri cu OTP:
36 36
 
37 37
 - `/api/hosts`
38
+- `/api/tags`
38 39
 - `/api/work-orders`
40
+- `/api/exports/hosts.yaml`
39 41
 - `/api/ca/status`
40 42
 - `/api/ca/certificates`
41 43
 - `/download/hosts.yaml`
42
-- `/download/local-hosts.tsv`
43 44
 - `/download/monitoring.json`
44 45
 - `/download/ca.crt`
45 46
 - `POST /api/hosts/upsert`
46 47
 - `POST /api/hosts/delete`
48
+- `POST /api/tags/upsert`
49
+- `POST /api/tags/rename`
50
+- `POST /api/tags/delete`
51
+- `POST /api/dns/publish`
47 52
 - `POST /api/work-orders/checklist`
48 53
 - `POST /api/work-orders/confirm`
49
-- `POST /api/render/local-hosts-tsv`
50 54
 
51 55
 ## Pornire locală
52 56
 
@@ -113,9 +117,9 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de
113 117
 ## Flux
114 118
 
115 119
 1. Hosturile se editează în aplicație; store-ul runtime este `var/host-manager.sqlite`.
116
-2. Operatorii autentificați pot descărca `/download/hosts.yaml`, `/download/local-hosts.tsv` sau `/download/monitoring.json`.
117
-3. Pentru DNS local, schimbările făcute în aplicație regenerează `config/local-hosts.tsv` din SQLite.
118
-4. Aplicația atinge `var/dns-publish.trigger`, iar pe jumper `host-manager-dns-publish.path` pornește prompt:
120
+2. Operatorii autentificați pot descărca `/download/hosts.yaml` sau `/download/monitoring.json`; UI-ul afișează și raw `hosts.yaml`.
121
+3. Pentru DNS local, schimbările făcute în aplicație pun în coadă acțiunea de sincronizare a resolverelor.
122
+4. Aplicația atinge `var/dns-publish.trigger`, iar pe jumper `host-manager-dns-publish.path` pornește:
119 123
 
120 124
 ```bash
121 125
 ./scripts/sync_local_hosts.sh --apply --verify
@@ -139,8 +143,7 @@ Confirmarea unui WO:
139 143
 - este blocată dacă există pași de checklist nemarcați `done`
140 144
 - elimină numele declarate din registry-ul SQLite
141 145
 - marchează WO-ul ca `confirmed`
142
-- regenerează `config/local-hosts.tsv`
143
-- declanșează publicarea către resolverele locale prin `host-manager-dns-publish.path`
146
+- pune în coadă sincronizarea resolverelor locale prin `host-manager-dns-publish.path`
144 147
 
145 148
 După confirmare, operatorul poate verifica manual resolverele cu:
146 149
 
@@ -172,7 +175,13 @@ Exemple:
172 175
 - `autonas01.madagascar.xdev.ro` produce automat aliasul `autonas01`
173 176
 - `pmx.baobab.madagascar.xdev.ro` produce automat aliasul `pmx.baobab`
174 177
 
175
-Aliasurile derivate nu se declară separat în `hosts.yaml`. Ele sunt păstrate în tabelul `host_aliases` și apar în API, monitoring export și `local-hosts.tsv` ca nume efective.
178
+Aliasurile derivate nu se declară separat în `hosts.yaml` și nu sunt afișate ca zgomot separat în UI. Ele sunt păstrate în tabelul `host_aliases` și pot apărea în exporturile tehnice care au nevoie de nume efective.
179
+
180
+## Taguri și observații
181
+
182
+Tagurile sunt învățate din tagurile atașate hosturilor și sunt gestionate în catalogul de taguri: redenumire, culoare, icon și asocieri pe host. Tagurile sunt exportate în `hosts.yaml`.
183
+
184
+`dhcp_leases` și `mdns_observations` sunt inputuri observate. Aplicația le afișează ca hinturi/autocomplete la adăugarea sau editarea de hosturi și vhosturi. Dacă există `nasturel.local` și operatorul tastează `nasturel` ca vhost/host, UI-ul poate propune IP-ul detectat sau atașarea la hostul existent cu acel IP; nu modifică registry-ul automat.
176 185
 
177 186
 ## Git și managementul cheilor
178 187
 
@@ -196,7 +205,7 @@ jumper
196 205
   sync_local_hosts.sh      aplică DNS după review/verificare
197 206
 
198 207
 servicii consumatoare
199
-  export verificat         citesc hosts.yaml/local-hosts.tsv/monitoring.json
208
+  export verificat         citesc hosts.yaml/monitoring.json
200 209
 ```
201 210
 
202 211
 Pentru etapa MVP, aplicația nu face commit/push automat. După o modificare, schimbarea rămâne în SQLite și poate fi exportată explicit pentru review/arhivare. Automatizarea commit/push pentru exporturi poate fi adăugată ulterior, dar numai cu cheie separată și reguli clare de semnare/audit.
@@ -237,4 +246,4 @@ Reguli:
237 246
 
238 247
 - Parserul YAML acceptă schema strictă generată de aplicație, nu YAML arbitrar.
239 248
 - Conflict engine-ul verifică doar consistența locală din registry-ul SQLite.
240
-- DHCP, mDNS, `hosts-local.yaml` și `madagascar.json` sunt încă surse pentru audit manual sau pentru următorul collector.
249
+- DHCP și mDNS sunt inputuri observate pentru hinturi/autocomplete, nu surse de adevăr pentru registry.
+9 -11
.doc/local-hosts.md
@@ -4,7 +4,7 @@ Rețeaua madagascar folosește un **DNS intern dual**: jumper (192.168.2.100) ca
4 4
 
5 5
 Numele vechi `is-vpn-gw` este deprecated. Folosește `jumper` sau `jumper.madagascar.xdev.ro` în scripturi și documentație.
6 6
 
7
-Regula importantă: local nu se folosește wildcard pentru `*.madagascar.xdev.ro`. Doar hostname-urile cunoscute din `config/local-hosts.tsv` se rezolvă local; orice nume necunoscut, inclusiv typo-uri precum `nohost.madagascar.xdev.ro`, trebuie să întoarcă `NXDOMAIN`.
7
+Regula importantă: local nu se folosește wildcard pentru `*.madagascar.xdev.ro`. Doar hostname-urile aprobate în SQLite și publicate prin acțiunea de sincronizare a resolverelor se rezolvă local; orice nume necunoscut, inclusiv typo-uri precum `nohost.madagascar.xdev.ro`, trebuie să întoarcă `NXDOMAIN`.
8 8
 
9 9
 Domeniul vechi `.vad.is.xdev.ro` este deprecated. Nu se adaugă intrări noi pentru el și nu se păstrează aliasuri locale pentru acest namespace.
10 10
 
@@ -27,9 +27,8 @@ Implementarea versionată este:
27 27
 | Fișier | Rol |
28 28
 |--------|-----|
29 29
 | `var/host-manager.sqlite` | sursa de adevăr runtime pentru registry și Work Orders |
30
-| `config/hosts.yaml` | seed/snapshot export pentru hosturi și FQDN-uri canonice |
31
-| `config/local-hosts.tsv` | export DNS generat din registry-ul SQLite, cu A records pentru hosturi/aliasuri de host și CNAME records pentru vhosturi |
32
-| `scripts/sync_local_hosts.sh` | citește manifestul DNS din SQLite-ul runtime de pe jumper și sincronizează `/etc/hosts`, `cloaking-rules.txt` și `/ip dns static` |
30
+| `config/hosts.yaml` | produs finit exportat pentru hosturi și FQDN-uri canonice, plus seed pentru baze noi |
31
+| `scripts/sync_local_hosts.sh` | generează recorduri efemere din SQLite-ul runtime de pe jumper și sincronizează `/etc/hosts`, `cloaking-rules.txt` și `/ip dns static` |
33 32
 
34 33
 `madagascar.xdev.ro` este domeniul implicit. Pentru orice host real `*.madagascar.xdev.ro`, aliasul scurt este derivat automat. De exemplu, `autonas01.madagascar.xdev.ro` publică și `autonas01`. Vhosturile, de exemplu `pmx.baobab.madagascar.xdev.ro`, se publică drept CNAME către hostul care le servește; aliasul scurt derivat, de exemplu `pmx.baobab`, este tot CNAME. Aliasurile derivate nu se declară separat în registry.
35 34
 
@@ -40,10 +39,9 @@ Când inventarele se contrazic, ordinea de încredere este:
40 39
 1. DHCP lease/reservation pe router (`admin@192.168.2.1`) — autoritatea pentru alocarea IP-urilor pe LAN. Configurațiile statice locale nu au voie să mute un IP peste DHCP; cel mult semnalează o rezervare lipsă sau o intrare veche.
41 40
 2. `cluster/cluster-context/madagascar.json` — autoritatea pentru roluri, topologie și IP-uri de serviciu. Pentru nodurile Proxmox, DNS-ul de serviciu poate folosi interfața `thunderbridge` (`192.168.10.x`) chiar dacă management/WAN este `192.168.2.x`.
42 41
 3. `var/host-manager.sqlite` — registry-ul operațional aprobat, editat prin Madagascar Local Authority.
43
-4. `config/local-hosts.tsv` — manifestul DNS local publicat pe jumper și as01. Acesta este un export al registry-ului SQLite și nu sursa primară pentru sync; sincronizarea citește manifestul runtime de pe jumper.
44
-5. `hosts-local.yaml` — inventar SSH: aliasuri, utilizatori, entrypoint-uri și căi de acces. IP-urile de aici sunt utile pentru audit, dar pot fi stale dacă DHCP spune altceva.
45
-6. mDNS (`*.local`) — sursă observată de descoperire și validare. Confirmă prezența unui host sau propune aliasuri, dar nu creează automat intrări `madagascar.xdev.ro`.
46
-7. DNS public — folosit doar pentru acces extern. Local, numele interne trebuie shadow-uite exact sau lăsate nerezolvate; wildcard-ul public nu este autoritate pentru LAN.
42
+4. `hosts-local.yaml` — inventar SSH istoric: aliasuri, utilizatori, entrypoint-uri și căi de acces. IP-urile de aici sunt utile pentru audit, dar pot fi stale dacă DHCP spune altceva.
43
+5. mDNS (`*.local`) — input observat de descoperire și validare. Confirmă prezența unui host sau propune aliasuri, dar nu creează automat intrări `madagascar.xdev.ro`.
44
+6. DNS public — folosit doar pentru acces extern. Local, numele interne trebuie shadow-uite exact sau lăsate nerezolvate; wildcard-ul public nu este autoritate pentru LAN.
47 45
 
48 46
 Reguli de împăcare:
49 47
 
@@ -51,7 +49,7 @@ Reguli de împăcare:
51 49
 - Pentru un IP de serviciu non-LAN, `madagascar.json` câștigă dacă explică explicit interfața sau rolul.
52 50
 - Pentru VM-uri, regula `vmid -> 192.168.2.vmid` este convenție de propunere și audit, nu autoritate. Rezervarea DHCP finală decide.
53 51
 - Prefixele istorice `is-`, `vad-` și `b-` se elimină la normalizarea numelor. `.vad.is.xdev.ro` rămâne deprecated și nu se reactivează prin aliasuri.
54
-- Dacă o sursă observată există doar în mDNS sau doar în lease-uri dinamice, se raportează ca propunere, nu se sincronizează automat în DNS.
52
+- Dacă o sursă observată există doar în mDNS sau doar în lease-uri dinamice, se afișează ca hint/autocomplete, nu se sincronizează automat în DNS.
55 53
 
56 54
 ## Listener mDNS
57 55
 
@@ -63,7 +61,7 @@ Locația implicită:
63 61
 var/host-manager.sqlite#mdns_observations
64 62
 ```
65 63
 
66
-Regulă importantă: listenerul mDNS scrie doar în tabelul `mdns_observations`; nu modifică registry-ul de hosturi, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review.
64
+Regulă importantă: listenerul mDNS scrie doar în tabelul `mdns_observations`; nu modifică registry-ul de hosturi sau `config/hosts.yaml`. Seed-ul mDNS rămâne observație separată până la review.
67 65
 
68 66
 Rulare manuală pentru test:
69 67
 
@@ -98,7 +96,7 @@ POST https://192.168.2.100/api/collect/dhcp-leases
98 96
 X-DHCP-Push-Token: <HOST_MANAGER_DHCP_PUSH_TOKEN>
99 97
 ```
100 98
 
101
-Endpoint-ul scrie doar în `dhcp_leases` și actualizează `data_workers.last_run_at`; nu modifică automat registry-ul de hosturi, `config/hosts.yaml` sau `config/local-hosts.tsv`.
99
+Endpoint-ul scrie doar în `dhcp_leases` și actualizează `data_workers.last_run_at`; nu modifică automat registry-ul de hosturi sau `config/hosts.yaml`.
102 100
 
103 101
 Scriptul RouterOS versionat este:
104 102
 
+4 -5
README.md
@@ -17,11 +17,10 @@ git@192.168.2.102:repositories/bogdan/LocalAuthority.git
17 17
 The runtime instance lives on jumper and remains the local source for operational registry data:
18 18
 
19 19
 - `var/host-manager.sqlite` - runtime source of truth for host registry and Work Orders
20
-- `config/hosts.yaml` - seed/snapshot export for host registry compatibility
21
-- `config/local-hosts.tsv` - DNS manifest export derived from the runtime SQLite registry
20
+- `config/hosts.yaml` - finished host registry export and seed for new databases
22 21
 - `config/work-orders.yaml` - seed/snapshot export for confirmable operational changes
23 22
 - `scripts/host_manager.pl` - Perl-only web app
24
-- `scripts/sync_local_hosts.sh` - local DNS sync to jumper and as01, sourced from the runtime DB on jumper
23
+- `scripts/sync_local_hosts.sh` - resolver configuration action for jumper and as01, sourced from the runtime DB on jumper
25 24
 - `scripts/ca_manager.sh` - local OpenSSL CA helper for host certificates
26 25
 
27 26
 The public `xdev.ro` zone is maintained in the separate DNS public-zone repository.
@@ -78,7 +77,7 @@ git push origin main
78 77
 tool, but the normal development loop is commit plus push: `jumper-runtime` for
79 78
 live testing, `origin`/GitPrep for archive and sharing.
80 79
 
81
-`config/` is not deployed by default. The live source of truth is `var/host-manager.sqlite`; `hosts.yaml`, `local-hosts.tsv`, and `work-orders.yaml` are seed/snapshot/export files that should not replace runtime data during normal code pushes. Deploy config only when intentionally replacing seed/export files:
80
+`config/` is not deployed by default. The live source of truth is `var/host-manager.sqlite`; `hosts.yaml` is the finished host export/seed and `work-orders.yaml` is a compatibility seed/snapshot. Deploy config only when intentionally replacing seed/export files:
82 81
 
83 82
 ```bash
84 83
 scripts/deploy_to_jumper.sh --include-config
@@ -86,7 +85,7 @@ scripts/deploy_to_jumper.sh --include-config
86 85
 
87 86
 The default internal domain is `madagascar.xdev.ro`. Short aliases are derived automatically from FQDNs, so `autonas01.madagascar.xdev.ro` also publishes `autonas01` without declaring it separately.
88 87
 
89
-Name removals with operational impact go through a Work Order. A WO records intent first; the operational checklist must be completed before confirmation can update the SQLite registry, mark the WO as confirmed, and regenerate `local-hosts.tsv`. Resolver sync remains an explicit operator step and reads the runtime manifest from the jumper database.
88
+Name removals with operational impact go through a Work Order. A WO records intent first; the operational checklist must be completed before confirmation can update the SQLite registry, mark the WO as confirmed, and queue resolver sync. Resolver sync remains an explicit operator action and reads runtime records from the jumper database.
90 89
 
91 90
 The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with:
92 91
 
+83 -106
config/hosts.yaml
@@ -1,152 +1,129 @@
1 1
 version: 1
2
-updated_at: "2026-06-05T00:00:00Z"
2
+updated_at: "2026-06-11T00:00:00Z"
3 3
 policy:
4
-  ip_authority: "dhcp"
5
-  topology_authority: "madagascar.json"
6
-  dns_manifest: "config/local-hosts.tsv"
7
-  storage_authority: "sqlite-relational"
4
+  finished_export: "hosts.yaml"
8 5
   runtime_database: "var/host-manager.sqlite"
9
-  consumer_access: "read-only deploy keys"
6
+  storage_authority: "sqlite-relational"
10 7
 hosts:
11
-  - id: "baobab"
12
-    status: "active"
13
-    ip: "192.168.10.91"
14
-    names:
15
-      - "baobab.madagascar.xdev.ro"
16
-      - "pmx.baobab.madagascar.xdev.ro"
17
-    roles:
18
-      - "proxmox"
19
-      - "pmx"
20
-    sources:
21
-      - "local-hosts.tsv"
22
-      - "madagascar.json"
23
-    monitoring: "pending"
24
-    notes: "Service DNS uses thunderbridge."
25
-  - id: "ebony"
8
+  - fqdn: "andrafiabe.madagascar.xdev.ro"
26 9
     status: "active"
27
-    ip: "192.168.10.92"
28
-    names:
29
-      - "ebony.madagascar.xdev.ro"
30
-      - "pmx.ebony.madagascar.xdev.ro"
10
+    ip: "192.168.2.96"
11
+    aliases:
12
+    vhosts:
13
+      - "pbs.andrafiabe.madagascar.xdev.ro"
31 14
     roles:
32
-      - "proxmox"
33
-      - "pmx"
34
-    sources:
35
-      - "local-hosts.tsv"
36
-      - "madagascar.json"
15
+      - "backup"
16
+      - "pbs"
17
+    tags:
37 18
     monitoring: "pending"
38
-    notes: "Service DNS uses thunderbridge."
39
-  - id: "tapia"
19
+    notes: ""
20
+  - fqdn: "anjothibe.madagascar.xdev.ro"
40 21
     status: "active"
41
-    ip: "192.168.10.93"
42
-    names:
43
-      - "tapia.madagascar.xdev.ro"
44
-      - "pmx.tapia.madagascar.xdev.ro"
22
+    ip: "192.168.2.95"
23
+    aliases:
24
+    vhosts:
25
+      - "pbs.anjothibe.madagascar.xdev.ro"
45 26
     roles:
46
-      - "proxmox"
47
-      - "pmx"
48
-    sources:
49
-      - "local-hosts.tsv"
50
-      - "madagascar.json"
27
+      - "backup"
28
+      - "pbs"
29
+    tags:
51 30
     monitoring: "pending"
52
-    notes: "Service DNS uses thunderbridge."
53
-  - id: "autonas01"
31
+    notes: ""
32
+  - fqdn: "autonas01.madagascar.xdev.ro"
54 33
     status: "active"
55 34
     ip: "192.168.10.21"
56
-    names:
57
-      - "autonas01.madagascar.xdev.ro"
35
+    aliases:
36
+    vhosts:
58 37
     roles:
59 38
       - "storage"
60
-    sources:
61
-      - "local-hosts.tsv"
39
+    tags:
62 40
     monitoring: "pending"
63 41
     notes: ""
64
-  - id: "autonas02"
42
+  - fqdn: "autonas02.madagascar.xdev.ro"
65 43
     status: "active"
66 44
     ip: "192.168.10.22"
67
-    names:
68
-      - "autonas02.madagascar.xdev.ro"
45
+    aliases:
46
+    vhosts:
69 47
     roles:
70 48
       - "storage"
71
-    sources:
72
-      - "local-hosts.tsv"
49
+    tags:
73 50
     monitoring: "pending"
74 51
     notes: ""
75
-  - id: "anjothibe"
52
+  - fqdn: "baobab.madagascar.xdev.ro"
76 53
     status: "active"
77
-    ip: "192.168.2.95"
78
-    names:
79
-      - "anjothibe.madagascar.xdev.ro"
80
-      - "pbs.anjothibe.madagascar.xdev.ro"
54
+    ip: "192.168.10.91"
55
+    aliases:
56
+    vhosts:
57
+      - "pmx.baobab.madagascar.xdev.ro"
81 58
     roles:
82
-      - "pbs"
83
-      - "backup"
84
-    sources:
85
-      - "local-hosts.tsv"
86
-      - "madagascar.json"
59
+      - "pmx"
60
+      - "proxmox"
61
+    tags:
87 62
     monitoring: "pending"
88
-    notes: ""
89
-  - id: "andrafiabe"
63
+    notes: "Service DNS uses thunderbridge."
64
+  - fqdn: "ebony.madagascar.xdev.ro"
90 65
     status: "active"
91
-    ip: "192.168.2.96"
92
-    names:
93
-      - "andrafiabe.madagascar.xdev.ro"
94
-      - "pbs.andrafiabe.madagascar.xdev.ro"
66
+    ip: "192.168.10.92"
67
+    aliases:
68
+    vhosts:
69
+      - "pmx.ebony.madagascar.xdev.ro"
95 70
     roles:
96
-      - "pbs"
97
-      - "backup"
98
-    sources:
99
-      - "local-hosts.tsv"
100
-      - "madagascar.json"
71
+      - "pmx"
72
+      - "proxmox"
73
+    tags:
101 74
     monitoring: "pending"
102
-    notes: ""
103
-  - id: "mazeri"
75
+    notes: "Service DNS uses thunderbridge."
76
+  - fqdn: "jumper.madagascar.xdev.ro"
77
+    status: "active"
78
+    ip: "192.168.2.100"
79
+    aliases:
80
+    vhosts:
81
+      - "hosts.madagascar.xdev.ro"
82
+    roles:
83
+      - "dns"
84
+      - "entrypoint"
85
+    tags:
86
+    monitoring: "enabled"
87
+    notes: "Cluster registry publishes only the routable address."
88
+  - fqdn: "mazeri.madagascar.xdev.ro"
104 89
     status: "active"
105 90
     ip: "192.168.2.102"
106
-    names:
107
-      - "mazeri.madagascar.xdev.ro"
91
+    aliases:
92
+    vhosts:
108 93
     roles:
109 94
       - "service"
110
-    sources:
111
-      - "local-hosts.tsv"
112
-      - "hosts-local.yaml"
95
+    tags:
113 96
     monitoring: "pending"
114 97
     notes: ""
115
-  - id: "toltec"
98
+  - fqdn: "tapia.madagascar.xdev.ro"
99
+    status: "active"
100
+    ip: "192.168.10.93"
101
+    aliases:
102
+    vhosts:
103
+      - "pmx.tapia.madagascar.xdev.ro"
104
+    roles:
105
+      - "pmx"
106
+      - "proxmox"
107
+    tags:
108
+    monitoring: "pending"
109
+    notes: "Service DNS uses thunderbridge."
110
+  - fqdn: "toltec.madagascar.xdev.ro"
116 111
     status: "active"
117 112
     ip: "192.168.2.103"
118
-    names:
119
-      - "toltec.madagascar.xdev.ro"
113
+    aliases:
114
+    vhosts:
120 115
     roles:
121 116
       - "service"
122
-    sources:
123
-      - "local-hosts.tsv"
124
-      - "hosts-local.yaml"
117
+    tags:
125 118
     monitoring: "pending"
126 119
     notes: ""
127
-  - id: "zabbix"
120
+  - fqdn: "zabbix.madagascar.xdev.ro"
128 121
     status: "active"
129 122
     ip: "192.168.2.107"
130
-    names:
131
-      - "zabbix.madagascar.xdev.ro"
123
+    aliases:
124
+    vhosts:
132 125
     roles:
133 126
       - "monitoring"
134
-    sources:
135
-      - "local-hosts.tsv"
136
-      - "hosts-local.yaml"
127
+    tags:
137 128
     monitoring: "enabled"
138 129
     notes: ""
139
-  - id: "jumper"
140
-    status: "active"
141
-    ip: "192.168.2.100"
142
-    names:
143
-      - "jumper.madagascar.xdev.ro"
144
-      - "hosts.madagascar.xdev.ro"
145
-    roles:
146
-      - "entrypoint"
147
-      - "dns"
148
-    sources:
149
-      - "local-hosts.tsv"
150
-      - "hosts-local.yaml"
151
-    monitoring: "enabled"
152
-    notes: "Cluster registry publishes only the routable address."
+0 -34
config/local-hosts.tsv
@@ -1,34 +0,0 @@
1
-# Local DNS manifest for the madagascar network.
2
-# Generated by scripts/host_manager.pl from the runtime SQLite registry.
3
-#
4
-# Format:
5
-# ip<TAB>name [aliases...]
6
-# CNAME<TAB>alias<TAB>target
7
-#
8
-# Priority rule:
9
-# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.
10
-# - madagascar.json is canonical for cluster roles and service interfaces.
11
-# - This file publishes approved local DNS records derived from those sources.
12
-192.168.2.96	andrafiabe.madagascar.xdev.ro andrafiabe
13
-CNAME	pbs.andrafiabe.madagascar.xdev.ro	andrafiabe.madagascar.xdev.ro
14
-CNAME	pbs.andrafiabe	andrafiabe.madagascar.xdev.ro
15
-192.168.2.95	anjothibe.madagascar.xdev.ro anjothibe
16
-CNAME	pbs.anjothibe.madagascar.xdev.ro	anjothibe.madagascar.xdev.ro
17
-CNAME	pbs.anjothibe	anjothibe.madagascar.xdev.ro
18
-192.168.10.21	autonas01.madagascar.xdev.ro autonas01
19
-192.168.10.22	autonas02.madagascar.xdev.ro autonas02
20
-192.168.10.91	baobab.madagascar.xdev.ro baobab
21
-CNAME	pmx.baobab.madagascar.xdev.ro	baobab.madagascar.xdev.ro
22
-CNAME	pmx.baobab	baobab.madagascar.xdev.ro
23
-192.168.10.92	ebony.madagascar.xdev.ro ebony
24
-CNAME	pmx.ebony.madagascar.xdev.ro	ebony.madagascar.xdev.ro
25
-CNAME	pmx.ebony	ebony.madagascar.xdev.ro
26
-192.168.2.100	jumper.madagascar.xdev.ro jumper
27
-CNAME	hosts.madagascar.xdev.ro	jumper.madagascar.xdev.ro
28
-CNAME	hosts	jumper.madagascar.xdev.ro
29
-192.168.2.102	mazeri.madagascar.xdev.ro mazeri
30
-192.168.10.93	tapia.madagascar.xdev.ro tapia
31
-CNAME	pmx.tapia.madagascar.xdev.ro	tapia.madagascar.xdev.ro
32
-CNAME	pmx.tapia	tapia.madagascar.xdev.ro
33
-192.168.2.103	toltec.madagascar.xdev.ro toltec
34
-192.168.2.107	zabbix.madagascar.xdev.ro zabbix
+3 -5
deploy/jumper/README.md
@@ -32,7 +32,6 @@ sudo dnf install nginx
32 32
 ```text
33 33
 /usr/local/xdev-host-manager
34 34
   config/hosts.yaml
35
-  config/local-hosts.tsv
36 35
   var/host-manager.sqlite
37 36
   scripts/host_manager.pl
38 37
   scripts/mdns_host_seed.pl
@@ -88,7 +87,7 @@ curl -k -o /dev/null -w '%{http_code}\n' https://madagascar.xdev.ro/healthz
88 87
 Verificări de securitate de bază:
89 88
 
90 89
 ```bash
91
-curl -k -o /dev/null -w '%{http_code}\n' -X POST https://madagascar.xdev.ro/api/render/local-hosts-tsv
90
+curl -k -o /dev/null -w '%{http_code}\n' -X POST https://madagascar.xdev.ro/api/dns/publish
92 91
 # trebuie să întoarcă 401 fără sesiune OTP
93 92
 ```
94 93
 
@@ -102,8 +101,7 @@ madagascar.xdev.ro -> jumper.madagascar.xdev.ro
102 101
 
103 102
 Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat.
104 103
 
105
-Schimbările DNS făcute prin aplicație regenerează `config/local-hosts.tsv` și
106
-ating `var/dns-publish.trigger`. Pe jumper,
104
+Schimbările DNS făcute prin aplicație ating `var/dns-publish.trigger`. Pe jumper,
107 105
 `host-manager-dns-publish.path` pornește imediat
108 106
 `host-manager-dns-publish.service`, care rulează:
109 107
 
@@ -120,7 +118,7 @@ Serviciul oneshot rulează ca root prin systemd, deoarece publicarea atinge
120 118
 
121 119
 ## mDNS discovery
122 120
 
123
-`host-manager-mdns` este un listener separat care observă mDNS și scrie direct în tabelul SQLite `mdns_observations`. Listenerul nu modifică host registry-ul, `config/hosts.yaml` sau `config/local-hosts.tsv`. Sync-ul resolverului citește manifestul runtime din SQLite, nu din exportul static.
121
+`host-manager-mdns` este un listener separat care observă mDNS și scrie direct în tabelul SQLite `mdns_observations`. Listenerul nu modifică host registry-ul sau `config/hosts.yaml`. Sync-ul resolverului generează recorduri efemere din SQLite, nu dintr-un export static.
124 122
 
125 123
 ## DHCP lease push
126 124
 
+0 -1
deploy/jumper/host-manager.env.example
@@ -5,7 +5,6 @@ HOST_MANAGER_BIND=127.0.0.1
5 5
 HOST_MANAGER_PORT=8088
6 6
 HOST_MANAGER_DB=/usr/local/xdev-host-manager/var/host-manager.sqlite
7 7
 HOST_MANAGER_DATA=/usr/local/xdev-host-manager/config/hosts.yaml
8
-HOST_MANAGER_LOCAL_HOSTS_TSV=/usr/local/xdev-host-manager/config/local-hosts.tsv
9 8
 HOST_MANAGER_DNS_PUBLISH_TRIGGER=/usr/local/xdev-host-manager/var/dns-publish.trigger
10 9
 
11 10
 # Base32 TOTP secret. Required for write access.
+875 -63
scripts/host_manager.pl
@@ -23,11 +23,11 @@ my %opt = (
23 23
     port => $ENV{HOST_MANAGER_PORT} || 8088,
24 24
     db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
25 25
     data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
26
-    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/var/local-hosts.tsv",
27 26
     dns_publish_trigger => $ENV{HOST_MANAGER_DNS_PUBLISH_TRIGGER} || "$project_dir/var/dns-publish.trigger",
28 27
     work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
29 28
 );
30
-my $print_local_hosts_tsv = 0;
29
+my $print_resolver_records = 0;
30
+my $print_hosts_yaml = 0;
31 31
 
32 32
 while (@ARGV) {
33 33
     my $arg = shift @ARGV;
@@ -39,12 +39,12 @@ while (@ARGV) {
39 39
         $opt{db} = shift @ARGV;
40 40
     } elsif ($arg eq '--data') {
41 41
         $opt{data} = shift @ARGV;
42
-    } elsif ($arg eq '--local-hosts-tsv') {
43
-        $opt{local_hosts_tsv} = shift @ARGV;
44 42
     } elsif ($arg eq '--work-orders') {
45 43
         $opt{work_orders} = shift @ARGV;
46
-    } elsif ($arg eq '--print-local-hosts-tsv') {
47
-        $print_local_hosts_tsv = 1;
44
+    } elsif ($arg eq '--print-hosts-yaml') {
45
+        $print_hosts_yaml = 1;
46
+    } elsif ($arg eq '--print-resolver-records' || $arg eq '--print-local-hosts-tsv') {
47
+        $print_resolver_records = 1;
48 48
     } elsif ($arg eq '--help' || $arg eq '-h') {
49 49
         usage();
50 50
         exit 0;
@@ -53,8 +53,13 @@ while (@ARGV) {
53 53
     }
54 54
 }
55 55
 
56
-if ($print_local_hosts_tsv) {
57
-    print render_local_hosts_tsv(load_registry());
56
+if ($print_hosts_yaml) {
57
+    print render_hosts_yaml(load_registry());
58
+    exit 0;
59
+}
60
+
61
+if ($print_resolver_records) {
62
+    print render_resolver_records(load_registry());
58 63
     exit 0;
59 64
 }
60 65
 
@@ -95,15 +100,16 @@ Environment:
95 100
   HOST_MANAGER_DHCP_PUSH_TOKEN  Token for DHCP lease push collector.
96 101
   HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
97 102
   HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
98
-  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
99 103
   HOST_MANAGER_DNS_PUBLISH_TRIGGER
100 104
                                   Defaults to var/dns-publish.trigger.
101 105
   HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
102
-  --print-local-hosts-tsv       Print the runtime DNS manifest and exit.
106
+  --print-hosts-yaml            Print the finished hosts.yaml export and exit.
107
+  --print-resolver-records      Print ephemeral resolver records and exit.
103 108
 
104
-SQLite is the runtime source of truth. YAML files seed a new database and remain
105
-download/export compatibility artifacts. The nginx vhost keeps registry, CA,
106
-work order and download endpoints behind OTP.
109
+SQLite is the runtime source of truth. hosts.yaml is the finished registry
110
+export. Resolver sync is an action sourced from SQLite, not a tracked TSV
111
+artifact. The nginx vhost keeps registry, CA, work order and download endpoints
112
+behind OTP.
107 113
 EOF
108 114
 }
109 115
 
@@ -164,9 +170,16 @@ sub handle_client {
164 170
         my $registry = load_registry();
165 171
         return send_json($client, 200, registry_payload($registry));
166 172
     }
173
+    if ($method eq 'GET' && $path eq '/api/tags') {
174
+        return send_json($client, 200, tags_payload(dbh()));
175
+    }
167 176
     if ($method eq 'GET' && $path eq '/api/work-orders') {
168 177
         return send_json($client, 200, work_orders_payload(load_work_orders()));
169 178
     }
179
+    if ($method eq 'GET' && $path eq '/api/exports/hosts.yaml') {
180
+        my $registry = load_registry();
181
+        return send_response($client, 200, render_hosts_yaml($registry), 'text/plain; charset=utf-8');
182
+    }
170 183
     if ($method eq 'GET' && $path eq '/api/debug/database/tables') {
171 184
         return send_json($client, 200, debug_database_tables_payload());
172 185
     }
@@ -187,10 +200,6 @@ sub handle_client {
187 200
         my $registry = load_registry();
188 201
         return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
189 202
     }
190
-    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
191
-        my $registry = load_registry();
192
-        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
193
-    }
194 203
     if ($method eq 'GET' && $path eq '/download/monitoring.json') {
195 204
         my $registry = load_registry();
196 205
         return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
@@ -220,7 +229,23 @@ sub handle_client {
220 229
         }
221 230
         if ($path eq '/api/hosts/delete') {
222 231
             my $payload = request_payload(\%headers, $body);
223
-            return delete_host($client, $payload->{id} || '');
232
+            return delete_host($client, $payload->{fqdn} || $payload->{id} || '');
233
+        }
234
+        if ($path eq '/api/tags/upsert') {
235
+            my $payload = request_payload(\%headers, $body);
236
+            return upsert_tag($client, $payload);
237
+        }
238
+        if ($path eq '/api/tags/rename') {
239
+            my $payload = request_payload(\%headers, $body);
240
+            return rename_tag($client, $payload);
241
+        }
242
+        if ($path eq '/api/tags/delete') {
243
+            my $payload = request_payload(\%headers, $body);
244
+            return delete_tag($client, $payload);
245
+        }
246
+        if ($path eq '/api/dns/publish') {
247
+            my $publish = publish_dns_change(load_registry(), 'manual-publish');
248
+            return send_json($client, 200, { ok => json_bool(1), dns_publish => $publish });
224 249
         }
225 250
         if ($path eq '/api/hosts/certificate') {
226 251
             my $payload = request_payload(\%headers, $body);
@@ -258,11 +283,6 @@ sub handle_client {
258 283
             my $payload = request_payload(\%headers, $body);
259 284
             return update_work_order_checklist($client, $payload);
260 285
         }
261
-        if ($path eq '/api/render/local-hosts-tsv') {
262
-            my $registry = load_registry();
263
-            my $publish = publish_dns_change($registry, 'manual-render');
264
-            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv}, dns_publish => $publish });
265
-        }
266 286
     }
267 287
 
268 288
     return send_json($client, 404, { error => 'not_found' });
@@ -270,7 +290,7 @@ sub handle_client {
270 290
 
271 291
 sub app_page_path {
272 292
     my ($path) = @_;
273
-    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
293
+    return $path =~ m{\A/(?:|overview|hosts|vhosts|tags|dns|work-orders|ca|debug)\z};
274 294
 }
275 295
 
276 296
 sub load_registry {
@@ -497,7 +517,6 @@ sub confirm_work_order {
497 517
         ok => json_bool(1),
498 518
         work_order => $work_order,
499 519
         results => $results,
500
-        local_hosts_tsv => $opt{local_hosts_tsv},
501 520
         dns_publish => $publish,
502 521
     });
503 522
 }
@@ -539,6 +558,83 @@ sub update_work_order_checklist {
539 558
     return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
540 559
 }
541 560
 
561
+sub upsert_tag {
562
+    my ($client, $payload) = @_;
563
+    my $label = clean_tag_label($payload->{label} || $payload->{tag} || $payload->{tag_id} || '');
564
+    return send_json($client, 400, { error => 'invalid_tag' }) unless length $label;
565
+    my $dbh = dbh();
566
+    my $now = iso_now();
567
+    my $tag = eval {
568
+        with_transaction($dbh, sub {
569
+            upsert_tag_to_db(
570
+                $dbh,
571
+                $label,
572
+                clean_tag_color($payload->{color} || ''),
573
+                clean_tag_icon($payload->{icon} || ''),
574
+                clean_scalar($payload->{notes} || ''),
575
+                $now,
576
+            );
577
+        });
578
+        tag_payload_by_label($dbh, $label);
579
+    };
580
+    if (!$tag) {
581
+        return send_json($client, 409, { error => 'tag_upsert_failed', detail => clean_scalar($@ || '') });
582
+    }
583
+    return send_json($client, 200, { ok => json_bool(1), tag => $tag });
584
+}
585
+
586
+sub rename_tag {
587
+    my ($client, $payload) = @_;
588
+    my $old_id = clean_tag_id($payload->{tag_id} || $payload->{old_label} || $payload->{old_tag} || '');
589
+    my $new_label = clean_tag_label($payload->{new_label} || $payload->{label} || '');
590
+    return send_json($client, 400, { error => 'invalid_tag' }) unless length $old_id && length $new_label;
591
+    my $new_id = clean_tag_id($new_label);
592
+    my $dbh = dbh();
593
+    return send_json($client, 404, { error => 'tag_not_found' })
594
+        unless db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ?', $old_id);
595
+    if ($new_id ne $old_id && db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ? OR label = ?', $new_id, $new_label)) {
596
+        return send_json($client, 409, { error => 'tag_exists' });
597
+    }
598
+    my $now = iso_now();
599
+    my $renamed = eval {
600
+        with_transaction($dbh, sub {
601
+            $dbh->do(
602
+                'UPDATE tags SET tag_id = ?, label = ?, color = ?, icon = ?, notes = ?, updated_at = ? WHERE tag_id = ?',
603
+                undef,
604
+                $new_id,
605
+                $new_label,
606
+                clean_tag_color($payload->{color} || ''),
607
+                clean_tag_icon($payload->{icon} || ''),
608
+                clean_scalar($payload->{notes} || ''),
609
+                $now,
610
+                $old_id,
611
+            );
612
+            set_schema_meta($dbh, 'registry_updated_at', $now);
613
+        });
614
+        tag_payload_by_label($dbh, $new_label);
615
+    };
616
+    if (!$renamed) {
617
+        return send_json($client, 409, { error => 'tag_rename_failed', detail => clean_scalar($@ || '') });
618
+    }
619
+    return send_json($client, 200, { ok => json_bool(1), tag => $renamed });
620
+}
621
+
622
+sub delete_tag {
623
+    my ($client, $payload) = @_;
624
+    my $tag_id = clean_tag_id($payload->{tag_id} || $payload->{label} || '');
625
+    return send_json($client, 400, { error => 'invalid_tag' }) unless length $tag_id;
626
+    my $dbh = dbh();
627
+    return send_json($client, 404, { error => 'tag_not_found' })
628
+        unless db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ?', $tag_id);
629
+    my $now = iso_now();
630
+    with_transaction($dbh, sub {
631
+        $dbh->do('DELETE FROM host_tags WHERE tag_id = ?', undef, $tag_id);
632
+        $dbh->do('DELETE FROM tags WHERE tag_id = ?', undef, $tag_id);
633
+        set_schema_meta($dbh, 'registry_updated_at', $now);
634
+    });
635
+    return send_json($client, 200, { ok => json_bool(1), tag_id => $tag_id });
636
+}
637
+
542 638
 sub incomplete_work_order_items {
543 639
     my ($work_order) = @_;
544 640
     my @incomplete;
@@ -584,9 +680,11 @@ sub registry_payload {
584 680
     my $problems = analyze_hosts($registry->{hosts});
585 681
     my $dbh = dbh();
586 682
     my %host_tls = host_tls_payloads($dbh);
587
-    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }) } @{ $registry->{hosts} };
683
+    my %tag_catalog = tag_catalog_by_label($dbh);
684
+    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }, \%tag_catalog) } @{ $registry->{hosts} };
588 685
     my @vhosts = vhost_payloads($dbh);
589 686
     my @certificates = certificate_payloads($dbh);
687
+    my $tags = tags_payload($dbh);
590 688
     my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
591 689
     return {
592 690
         version => $registry->{version},
@@ -594,6 +692,8 @@ sub registry_payload {
594 692
         policy => $registry->{policy},
595 693
         hosts => \@hosts,
596 694
         vhosts => \@vhosts,
695
+        tags => $tags->{tags},
696
+        observations => observations_payload($dbh),
597 697
         certificates => \@certificates,
598 698
         problems => $problems,
599 699
         counts => {
@@ -758,6 +858,7 @@ sub upsert_host {
758 858
         aliases => \@aliases,
759 859
         vhosts => \@vhosts,
760 860
         roles => [ clean_list($payload->{roles}) ],
861
+        tags => [ clean_tag_labels($payload->{tags}) ],
761 862
         sources => [ clean_list($payload->{sources}) ],
762 863
         monitoring => clean_scalar($payload->{monitoring} || 'pending'),
763 864
         notes => clean_scalar($payload->{notes} || ''),
@@ -786,12 +887,15 @@ sub upsert_host {
786 887
 }
787 888
 
788 889
 sub delete_host {
789
-    my ($client, $id) = @_;
790
-    $id = clean_id($id);
791
-    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
890
+    my ($client, $target) = @_;
891
+    my $fqdn = normalize_dns_name($target || '');
892
+    my $id = clean_id($target || '');
893
+    return send_json($client, 400, { error => 'invalid_host' }) unless $fqdn || $id;
792 894
 
793 895
     my $registry = load_registry();
794
-    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
896
+    my @kept = grep {
897
+        (canonical_host_fqdn($_) ne $fqdn) && (($_->{id} || '') ne $id)
898
+    } @{ $registry->{hosts} };
795 899
     return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
796 900
     $registry->{hosts} = \@kept;
797 901
     save_registry($registry);
@@ -1088,16 +1192,20 @@ sub analyze_hosts {
1088 1192
 }
1089 1193
 
1090 1194
 sub host_payload {
1091
-    my ($host, $tls) = @_;
1195
+    my ($host, $tls, $tag_catalog) = @_;
1196
+    $tls ||= {};
1197
+    $tag_catalog ||= {};
1092 1198
     my %copy = %$host;
1093 1199
     $copy{fqdn} = canonical_host_fqdn($host);
1094 1200
     $copy{ip} = canonical_ip($host);
1095
-    $copy{names} = [ effective_names($host) ];
1096
-    $copy{declared_names} = [ declared_dns_names($host) ];
1097 1201
     $copy{aliases} = [ declared_alias_names($host) ];
1098 1202
     $copy{derived_aliases} = [ derived_alias_names($host) ];
1099 1203
     $copy{vhosts} = [ declared_vhost_names($host) ];
1100 1204
     $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
1205
+    $copy{tags} = [ clean_tag_labels($host->{tags}) ];
1206
+    $copy{tag_details} = [
1207
+        map { $tag_catalog->{$_} || default_tag_payload($_) } @{ $copy{tags} }
1208
+    ];
1101 1209
     $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
1102 1210
     $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
1103 1211
     return \%copy;
@@ -1262,10 +1370,11 @@ sub problem {
1262 1370
     return { host_id => $host->{id}, code => $code, message => $message };
1263 1371
 }
1264 1372
 
1265
-sub render_local_hosts_tsv {
1373
+sub render_resolver_records {
1266 1374
     my ($registry) = @_;
1267
-    my $out = "# Local DNS manifest for the madagascar network.\n";
1375
+    my $out = "# Ephemeral resolver records for the madagascar network.\n";
1268 1376
     $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
1377
+    $out .= "# hosts.yaml is the finished export; this stream is consumed by resolver sync only.\n";
1269 1378
     $out .= "#\n";
1270 1379
     $out .= "# Format:\n";
1271 1380
     $out .= "# ip<TAB>name [aliases...]\n";
@@ -1308,6 +1417,7 @@ sub render_monitoring {
1308 1417
             vhosts_declared => [ declared_vhost_names($host) ],
1309 1418
             vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
1310 1419
             roles => [ @{ $host->{roles} || [] } ],
1420
+            tags => [ clean_tag_labels($host->{tags}) ],
1311 1421
             monitoring => $host->{monitoring} || 'pending',
1312 1422
             notes => $host->{notes} || '',
1313 1423
         };
@@ -1594,19 +1704,26 @@ sub parse_hosts_yaml {
1594 1704
             $section = 'hosts';
1595 1705
         } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1596 1706
             $registry{policy}{$1} = yaml_unquote($2);
1597
-        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1707
+        } elsif (($section || '') eq 'hosts' && $line =~ /^  - (id|fqdn):\s*(.+)$/) {
1598 1708
             $current = {
1599
-                id => yaml_unquote($1),
1709
+                id => '',
1600 1710
                 fqdn => '',
1601 1711
                 status => 'active',
1602 1712
                 ip => '',
1603 1713
                 aliases => [],
1604 1714
                 vhosts => [],
1605 1715
                 roles => [],
1716
+                tags => [],
1606 1717
                 sources => [],
1607 1718
                 monitoring => 'pending',
1608 1719
                 notes => '',
1609 1720
             };
1721
+            if ($1 eq 'id') {
1722
+                $current->{id} = yaml_unquote($2);
1723
+            } else {
1724
+                $current->{fqdn} = normalize_dns_name(yaml_unquote($2));
1725
+                $current->{id} = legacy_id_from_fqdn($current->{fqdn});
1726
+            }
1610 1727
             push @{ $registry{hosts} }, $current;
1611 1728
             $list_key = undef;
1612 1729
         } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
@@ -1641,6 +1758,7 @@ sub parse_hosts_yaml {
1641 1758
         }
1642 1759
         delete $host->{names};
1643 1760
         $host->{fqdn} ||= canonical_host_fqdn($host);
1761
+        $host->{id} ||= legacy_id_from_fqdn($host->{fqdn});
1644 1762
     }
1645 1763
     return \%registry;
1646 1764
 }
@@ -1654,12 +1772,11 @@ sub render_hosts_yaml {
1654 1772
         $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1655 1773
     }
1656 1774
     $out .= "hosts:\n";
1657
-    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1658
-        $out .= "  - id: " . yq($host->{id}) . "\n";
1659
-        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1775
+    for my $host (sort { canonical_host_fqdn($a) cmp canonical_host_fqdn($b) } @{ $registry->{hosts} || [] }) {
1776
+        $out .= "  - fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1660 1777
         $out .= "    status: " . yq($host->{status} || '') . "\n";
1661 1778
         $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1662
-        for my $key (qw(aliases vhosts roles sources)) {
1779
+        for my $key (qw(aliases vhosts roles tags)) {
1663 1780
             $out .= "    $key:\n";
1664 1781
             for my $value (@{ $host->{$key} || [] }) {
1665 1782
                 $out .= "      - " . yq($value) . "\n";
@@ -1976,6 +2093,49 @@ sub clean_mac {
1976 2093
     return '';
1977 2094
 }
1978 2095
 
2096
+sub clean_tag_label {
2097
+    my ($value) = @_;
2098
+    $value = lc clean_scalar($value || '');
2099
+    $value =~ s/[^a-z0-9_. -]+/-/g;
2100
+    $value =~ s/\s+/-/g;
2101
+    $value =~ s/-+/-/g;
2102
+    $value =~ s/^-+|-+$//g;
2103
+    return $value;
2104
+}
2105
+
2106
+sub clean_tag_id {
2107
+    my ($value) = @_;
2108
+    return clean_id(clean_tag_label($value || ''));
2109
+}
2110
+
2111
+sub clean_tag_labels {
2112
+    my ($value) = @_;
2113
+    return unique_preserve(grep { length $_ } map { clean_tag_label($_) } clean_list($value));
2114
+}
2115
+
2116
+sub default_tag_color {
2117
+    return '#647084';
2118
+}
2119
+
2120
+sub default_tag_icon {
2121
+    return 'tag';
2122
+}
2123
+
2124
+sub clean_tag_color {
2125
+    my ($value) = @_;
2126
+    $value = clean_scalar($value || '');
2127
+    return lc $value if $value =~ /\A#[0-9a-fA-F]{6}\z/;
2128
+    return default_tag_color();
2129
+}
2130
+
2131
+sub clean_tag_icon {
2132
+    my ($value) = @_;
2133
+    $value = lc clean_scalar($value || '');
2134
+    $value =~ s/[^a-z0-9_-]+/-/g;
2135
+    $value =~ s/^-+|-+$//g;
2136
+    return length($value) ? $value : default_tag_icon();
2137
+}
2138
+
1979 2139
 sub normalize_dhcp_name {
1980 2140
     my ($value) = @_;
1981 2141
     $value = normalize_dns_name($value || '');
@@ -2173,13 +2333,10 @@ sub publish_dns_change {
2173 2333
     my ($registry, $reason) = @_;
2174 2334
     $reason = clean_scalar($reason || 'registry-change');
2175 2335
 
2176
-    backup_file($opt{local_hosts_tsv});
2177
-    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
2178
-
2179 2336
     my $trigger = $opt{dns_publish_trigger} || '';
2180 2337
     return {
2181 2338
         queued => json_bool(0),
2182
-        file => $opt{local_hosts_tsv},
2339
+        action => 'resolver-sync',
2183 2340
         reason => $reason,
2184 2341
     } unless length $trigger;
2185 2342
 
@@ -2190,7 +2347,7 @@ sub publish_dns_change {
2190 2347
 
2191 2348
     return {
2192 2349
         queued => json_bool(1),
2193
-        file => $opt{local_hosts_tsv},
2350
+        action => 'resolver-sync',
2194 2351
         trigger => $trigger,
2195 2352
         reason => $reason,
2196 2353
     };
@@ -2239,7 +2396,7 @@ SQL
2239 2396
     $dbh->do(
2240 2397
         'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2241 2398
         . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2242
-        undef, 'schema_version', '2', iso_now()
2399
+        undef, 'schema_version', '3', iso_now()
2243 2400
     );
2244 2401
     $dbh->do(<<'SQL');
2245 2402
 CREATE TABLE IF NOT EXISTS hosts (
@@ -2309,6 +2466,33 @@ CREATE TABLE IF NOT EXISTS host_flags (
2309 2466
     PRIMARY KEY (host_fqdn, flag),
2310 2467
     FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2311 2468
 )
2469
+SQL
2470
+    $dbh->do(<<'SQL');
2471
+CREATE TABLE IF NOT EXISTS tags (
2472
+    tag_id TEXT PRIMARY KEY,
2473
+    label TEXT NOT NULL UNIQUE,
2474
+    color TEXT NOT NULL DEFAULT '#647084',
2475
+    icon TEXT NOT NULL DEFAULT 'tag',
2476
+    notes TEXT NOT NULL DEFAULT '',
2477
+    created_at TEXT NOT NULL,
2478
+    updated_at TEXT NOT NULL
2479
+)
2480
+SQL
2481
+    $dbh->do(<<'SQL');
2482
+CREATE TABLE IF NOT EXISTS host_tags (
2483
+    host_fqdn TEXT NOT NULL,
2484
+    tag_id TEXT NOT NULL,
2485
+    status TEXT NOT NULL DEFAULT 'active',
2486
+    created_at TEXT NOT NULL,
2487
+    retired_at TEXT,
2488
+    PRIMARY KEY (host_fqdn, tag_id),
2489
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2490
+    FOREIGN KEY (tag_id) REFERENCES tags(tag_id) ON UPDATE CASCADE ON DELETE RESTRICT
2491
+)
2492
+SQL
2493
+    $dbh->do(<<'SQL');
2494
+CREATE INDEX IF NOT EXISTS idx_host_tags_tag_status
2495
+ON host_tags(tag_id, status)
2312 2496
 SQL
2313 2497
     $dbh->do(<<'SQL');
2314 2498
 CREATE TABLE IF NOT EXISTS host_ssh (
@@ -2568,6 +2752,7 @@ sub load_registry_from_db {
2568 2752
             aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2569 2753
             vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
2570 2754
             roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2755
+            tags => [ active_tags_for_host($dbh, $fqdn) ],
2571 2756
             sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2572 2757
             monitoring => $row->{monitoring},
2573 2758
             notes => $row->{notes},
@@ -2626,6 +2811,7 @@ sub upsert_host_to_db {
2626 2811
 
2627 2812
     sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2628 2813
     sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
2814
+    sync_host_tags($dbh, $fqdn, [ clean_tag_labels($host->{tags}) ]);
2629 2815
     sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
2630 2816
     return $fqdn;
2631 2817
 }
@@ -2667,6 +2853,54 @@ sub sync_host_values {
2667 2853
     }
2668 2854
 }
2669 2855
 
2856
+sub sync_host_tags {
2857
+    my ($dbh, $fqdn, $labels) = @_;
2858
+    my $now = iso_now();
2859
+    my %active;
2860
+    for my $label (@$labels) {
2861
+        $label = clean_tag_label($label);
2862
+        next unless length $label;
2863
+        my $tag_id = upsert_tag_to_db($dbh, $label, '', '', '', $now);
2864
+        next unless length $tag_id;
2865
+        $active{$tag_id} = 1;
2866
+        $dbh->do(
2867
+            "INSERT INTO host_tags (host_fqdn, tag_id, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2868
+            . "ON CONFLICT(host_fqdn, tag_id) DO UPDATE SET status = 'active', retired_at = ''",
2869
+            undef,
2870
+            $fqdn, $tag_id, $now,
2871
+        );
2872
+    }
2873
+
2874
+    my $sth = $dbh->prepare("SELECT tag_id FROM host_tags WHERE host_fqdn = ? AND status = 'active'");
2875
+    $sth->execute($fqdn);
2876
+    while (my ($tag_id) = $sth->fetchrow_array) {
2877
+        next if $active{$tag_id};
2878
+        $dbh->do("UPDATE host_tags SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND tag_id = ?", undef, $now, $fqdn, $tag_id);
2879
+    }
2880
+}
2881
+
2882
+sub upsert_tag_to_db {
2883
+    my ($dbh, $label, $color, $icon, $notes, $now) = @_;
2884
+    $label = clean_tag_label($label);
2885
+    return '' unless length $label;
2886
+    my $tag_id = clean_tag_id($label);
2887
+    $color = clean_tag_color($color || '');
2888
+    $icon = clean_tag_icon($icon || '');
2889
+    $notes = clean_scalar($notes || '');
2890
+    $dbh->do(
2891
+        'INSERT INTO tags (tag_id, label, color, icon, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) '
2892
+        . 'ON CONFLICT(tag_id) DO UPDATE SET label = excluded.label, '
2893
+        . 'color = CASE WHEN excluded.color <> ? THEN excluded.color ELSE tags.color END, '
2894
+        . 'icon = CASE WHEN excluded.icon <> ? THEN excluded.icon ELSE tags.icon END, '
2895
+        . 'notes = CASE WHEN excluded.notes <> ? THEN excluded.notes ELSE tags.notes END, '
2896
+        . 'updated_at = excluded.updated_at',
2897
+        undef,
2898
+        $tag_id, $label, $color, $icon, $notes, $now, $now,
2899
+        default_tag_color(), default_tag_icon(), '',
2900
+    );
2901
+    return $tag_id;
2902
+}
2903
+
2670 2904
 sub sync_host_aliases_and_vhosts {
2671 2905
     my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
2672 2906
     my $now = iso_now();
@@ -2769,6 +3003,7 @@ sub retire_host_in_db {
2769 3003
     $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2770 3004
     $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2771 3005
     $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
3006
+    $dbh->do("UPDATE host_tags SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2772 3007
 }
2773 3008
 
2774 3009
 sub active_aliases_for_host {
@@ -2804,6 +3039,198 @@ sub active_values_for_host {
2804 3039
     return @values;
2805 3040
 }
2806 3041
 
3042
+sub active_tags_for_host {
3043
+    my ($dbh, $fqdn) = @_;
3044
+    my @values;
3045
+    my $sth = $dbh->prepare(<<'SQL');
3046
+SELECT t.label
3047
+FROM host_tags ht
3048
+JOIN tags t ON t.tag_id = ht.tag_id
3049
+WHERE ht.host_fqdn = ? AND ht.status = 'active'
3050
+ORDER BY t.label
3051
+SQL
3052
+    $sth->execute($fqdn);
3053
+    while (my ($label) = $sth->fetchrow_array) {
3054
+        push @values, clean_tag_label($label);
3055
+    }
3056
+    return @values;
3057
+}
3058
+
3059
+sub tags_payload {
3060
+    my ($dbh) = @_;
3061
+    my @tags;
3062
+    my $sth = $dbh->prepare(<<'SQL');
3063
+SELECT
3064
+    t.tag_id,
3065
+    t.label,
3066
+    t.color,
3067
+    t.icon,
3068
+    t.notes,
3069
+    COUNT(CASE WHEN ht.status = 'active' THEN 1 END) AS host_count
3070
+FROM tags t
3071
+LEFT JOIN host_tags ht ON ht.tag_id = t.tag_id
3072
+GROUP BY t.tag_id, t.label, t.color, t.icon, t.notes
3073
+ORDER BY t.label
3074
+SQL
3075
+    $sth->execute;
3076
+    while (my $row = $sth->fetchrow_hashref) {
3077
+        push @tags, tag_payload($row);
3078
+    }
3079
+    return {
3080
+        tags => \@tags,
3081
+        counts => {
3082
+            tags => scalar @tags,
3083
+            associations => sum(map { $_->{host_count} || 0 } @tags),
3084
+        },
3085
+    };
3086
+}
3087
+
3088
+sub tag_catalog_by_label {
3089
+    my ($dbh) = @_;
3090
+    my %tags;
3091
+    for my $tag (@{ tags_payload($dbh)->{tags} || [] }) {
3092
+        $tags{ clean_tag_label($tag->{label}) } = $tag;
3093
+    }
3094
+    return %tags;
3095
+}
3096
+
3097
+sub tag_payload_by_label {
3098
+    my ($dbh, $label) = @_;
3099
+    $label = clean_tag_label($label);
3100
+    my $row = $dbh->selectrow_hashref(<<'SQL', undef, $label);
3101
+SELECT
3102
+    t.tag_id,
3103
+    t.label,
3104
+    t.color,
3105
+    t.icon,
3106
+    t.notes,
3107
+    COUNT(CASE WHEN ht.status = 'active' THEN 1 END) AS host_count
3108
+FROM tags t
3109
+LEFT JOIN host_tags ht ON ht.tag_id = t.tag_id
3110
+WHERE t.label = ?
3111
+GROUP BY t.tag_id, t.label, t.color, t.icon, t.notes
3112
+SQL
3113
+    return $row ? tag_payload($row) : default_tag_payload($label);
3114
+}
3115
+
3116
+sub tag_payload {
3117
+    my ($row) = @_;
3118
+    return {
3119
+        tag_id => clean_tag_id($row->{tag_id} || $row->{label} || ''),
3120
+        label => clean_tag_label($row->{label} || $row->{tag_id} || ''),
3121
+        color => clean_tag_color($row->{color} || ''),
3122
+        icon => clean_tag_icon($row->{icon} || ''),
3123
+        notes => clean_scalar($row->{notes} || ''),
3124
+        host_count => int($row->{host_count} || 0),
3125
+    };
3126
+}
3127
+
3128
+sub default_tag_payload {
3129
+    my ($label) = @_;
3130
+    $label = clean_tag_label($label);
3131
+    return {
3132
+        tag_id => clean_tag_id($label),
3133
+        label => $label,
3134
+        color => default_tag_color(),
3135
+        icon => default_tag_icon(),
3136
+        notes => '',
3137
+        host_count => 0,
3138
+    };
3139
+}
3140
+
3141
+sub observations_payload {
3142
+    my ($dbh) = @_;
3143
+    my @dhcp = observed_dhcp_leases($dbh);
3144
+    my @mdns = observed_mdns_records($dbh);
3145
+    return {
3146
+        dhcp_leases => \@dhcp,
3147
+        mdns_observations => \@mdns,
3148
+        counts => {
3149
+            dhcp_leases => scalar @dhcp,
3150
+            mdns_observations => scalar @mdns,
3151
+        },
3152
+    };
3153
+}
3154
+
3155
+sub observed_dhcp_leases {
3156
+    my ($dbh) = @_;
3157
+    my @rows;
3158
+    my $sth = $dbh->prepare(<<'SQL');
3159
+SELECT lease_key, worker_id, host_fqdn, observed_name, ip_address, mac_address, lease_state, first_seen, last_seen
3160
+FROM dhcp_leases
3161
+ORDER BY last_seen DESC
3162
+LIMIT 200
3163
+SQL
3164
+    $sth->execute;
3165
+    while (my $row = $sth->fetchrow_hashref) {
3166
+        push @rows, observation_payload_row($dbh, 'dhcp', $row);
3167
+    }
3168
+    return @rows;
3169
+}
3170
+
3171
+sub observed_mdns_records {
3172
+    my ($dbh) = @_;
3173
+    my @rows;
3174
+    my $sth = $dbh->prepare(<<'SQL');
3175
+SELECT observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer
3176
+FROM mdns_observations
3177
+ORDER BY last_seen DESC
3178
+LIMIT 200
3179
+SQL
3180
+    $sth->execute;
3181
+    while (my $row = $sth->fetchrow_hashref) {
3182
+        push @rows, observation_payload_row($dbh, 'mdns', $row);
3183
+    }
3184
+    return @rows;
3185
+}
3186
+
3187
+sub observation_payload_row {
3188
+    my ($dbh, $source, $row) = @_;
3189
+    my $name = normalize_dns_name($row->{observed_name} || '');
3190
+    my $ip = clean_ip($row->{ip_address} || '');
3191
+    my $candidate_fqdn = candidate_fqdn_for_observed_name($name);
3192
+    my $host_by_ip = host_fqdn_for_ip($dbh, $ip);
3193
+    return {
3194
+        source => $source,
3195
+        key => clean_scalar($row->{lease_key} || $row->{observation_key} || ''),
3196
+        worker_id => clean_scalar($row->{worker_id} || ''),
3197
+        observed_name => $name,
3198
+        candidate_fqdn => $candidate_fqdn,
3199
+        ip_address => $ip,
3200
+        mac_address => clean_mac($row->{mac_address} || ''),
3201
+        state => clean_scalar($row->{lease_state} || $row->{rr_type} || ''),
3202
+        host_fqdn => clean_scalar($row->{host_fqdn} || ''),
3203
+        existing_host_fqdn => $host_by_ip,
3204
+        first_seen => clean_scalar($row->{first_seen} || ''),
3205
+        last_seen => clean_scalar($row->{last_seen} || ''),
3206
+        seen_count => int($row->{seen_count} || 0),
3207
+    };
3208
+}
3209
+
3210
+sub candidate_fqdn_for_observed_name {
3211
+    my ($name) = @_;
3212
+    $name = normalize_dns_name($name || '');
3213
+    return '' unless length $name;
3214
+    $name =~ s/\.local\z//;
3215
+    return '' unless length $name;
3216
+    return $name if $name =~ /\.madagascar\.xdev\.ro\z/;
3217
+    return "$name.madagascar.xdev.ro" unless $name =~ /\./;
3218
+    return '';
3219
+}
3220
+
3221
+sub host_fqdn_for_ip {
3222
+    my ($dbh, $ip) = @_;
3223
+    return '' unless clean_ip($ip || '');
3224
+    my ($fqdn) = $dbh->selectrow_array(
3225
+        'SELECT fqdn FROM hosts WHERE (dns_ip = ? OR hosts_ip = ?) AND status <> ? ORDER BY fqdn LIMIT 1',
3226
+        undef,
3227
+        $ip,
3228
+        $ip,
3229
+        'retired',
3230
+    );
3231
+    return $fqdn || '';
3232
+}
3233
+
2807 3234
 sub load_work_orders_from_db {
2808 3235
     my $dbh = dbh();
2809 3236
     my $orders = { version => 1, work_orders => [] };
@@ -3073,6 +3500,8 @@ sub normalize_registry_policy {
3073 3500
     $registry->{policy} ||= {};
3074 3501
     $registry->{policy}{storage_authority} = 'sqlite-relational';
3075 3502
     $registry->{policy}{runtime_database} = $opt{db};
3503
+    $registry->{policy}{finished_export} = 'hosts.yaml';
3504
+    delete $registry->{policy}{dns_manifest};
3076 3505
 }
3077 3506
 
3078 3507
 sub default_hosts_yaml {
@@ -3473,6 +3902,20 @@ sub app_html {
3473 3902
     .host-actions { display: flex; align-items: center; gap: 6px; }
3474 3903
     .field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3475 3904
     .host-cert-cell { min-width: 0; }
3905
+    .tag-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3906
+    .tag-pill { display: inline-flex; align-items: center; gap: 4px; margin: 0; color: var(--ink); border-color: var(--line); background: #fff; }
3907
+    .tag-dot { width: 8px; height: 8px; border-radius: 999px; flex: 0 0 auto; background: var(--muted); }
3908
+    .tag-editor { display: grid; grid-template-columns: minmax(220px, 1fr) 90px 150px auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
3909
+    .tag-actions { display: flex; gap: 6px; flex-wrap: wrap; }
3910
+    .tag-actions button { min-height: 30px; padding: 4px 8px; }
3911
+    .raw-export { margin: 0; padding: 14px; min-height: 280px; max-height: 62vh; overflow: auto; background: #101827; color: #edf2ff; white-space: pre; }
3912
+    .observation-hints { display: grid; gap: 6px; padding: 0 10px 10px; color: var(--muted); }
3913
+    .host-inline-editor-shell .observation-hints { padding: 0; }
3914
+    .observation-card { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: #f8fafc; }
3915
+    .observation-card-main { display: grid; gap: 2px; min-width: 0; }
3916
+    .observation-card-title { color: var(--ink); font-weight: 650; overflow-wrap: anywhere; }
3917
+    .observation-card-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
3918
+    .observation-card button { min-height: 30px; padding: 4px 8px; }
3476 3919
     #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3477 3920
     #page-vhosts .host-tools { flex-wrap: wrap; }
3478 3921
     #page-vhosts .host-tools input { max-width: 280px; }
@@ -3515,6 +3958,7 @@ sub app_html {
3515 3958
       .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3516 3959
       .host-inline-editor-tools { justify-content: flex-start; }
3517 3960
       .debug-controls { align-items: stretch; }
3961
+      .tag-editor { grid-template-columns: 1fr; }
3518 3962
       .grid { grid-template-columns: 1fr; }
3519 3963
       table { min-width: 760px; }
3520 3964
       .table-wrap { overflow-x: auto; }
@@ -3566,6 +4010,7 @@ sub app_html {
3566 4010
         <a href="/overview" data-page-link="overview">Overview</a>
3567 4011
         <a href="/hosts" data-page-link="hosts">Hosts</a>
3568 4012
         <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
4013
+        <a href="/tags" data-page-link="tags">Tags</a>
3569 4014
         <a href="/dns" data-page-link="dns">DNS</a>
3570 4015
         <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3571 4016
         <a href="/ca" data-page-link="ca">Local CA</a>
@@ -3606,6 +4051,7 @@ sub app_html {
3606 4051
                   <th style="width: 140px">IP</th>
3607 4052
                   <th>Aliases</th>
3608 4053
                   <th style="width: 150px">Roles</th>
4054
+                  <th style="width: 160px">Tags</th>
3609 4055
                   <th style="width: 260px">Certificate</th>
3610 4056
                   <th style="width: 110px">Monitoring</th>
3611 4057
                   <th style="width: 90px">Status</th>
@@ -3628,10 +4074,12 @@ sub app_html {
3628 4074
             </div>
3629 4075
           </div>
3630 4076
           <div class="vhost-inline-editor">
3631
-            <input id="vhost-new-name" placeholder="vhost fqdn">
4077
+            <input id="vhost-new-name" placeholder="vhost fqdn" list="vhost-name-hints">
3632 4078
             <select id="vhost-new-host"></select>
3633 4079
             <button type="button" id="vhost-add">Add</button>
3634 4080
           </div>
4081
+          <datalist id="vhost-name-hints"></datalist>
4082
+          <div id="vhost-observation-hints" class="observation-hints"></div>
3635 4083
           <div class="table-wrap">
3636 4084
             <table>
3637 4085
               <thead>
@@ -3652,9 +4100,51 @@ sub app_html {
3652 4100
       <section class="page" id="page-dns" data-page="dns" hidden>
3653 4101
         <section class="toolbar">
3654 4102
           <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3655
-          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3656 4103
           <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3657
-          <button id="write-tsv">Write local-hosts.tsv</button>
4104
+          <button id="publish-dns">Publish resolvers</button>
4105
+        </section>
4106
+        <section class="panel">
4107
+          <div class="panel-head">
4108
+            <h2>hosts.yaml</h2>
4109
+            <div class="stats" id="hosts-yaml-stats"></div>
4110
+          </div>
4111
+          <pre class="raw-export mono" id="hosts-yaml-raw"></pre>
4112
+        </section>
4113
+      </section>
4114
+
4115
+      <section class="page" id="page-tags" data-page="tags" hidden>
4116
+        <section class="panel">
4117
+          <div class="panel-head">
4118
+            <h2>Tags</h2>
4119
+            <div class="stats" id="tag-stats"></div>
4120
+          </div>
4121
+          <div class="tag-editor">
4122
+            <input id="tag-new-label" placeholder="tag">
4123
+            <input id="tag-new-color" type="color" value="#647084" aria-label="Tag color">
4124
+            <select id="tag-new-icon" aria-label="Tag icon">
4125
+              <option value="tag">tag</option>
4126
+              <option value="server">server</option>
4127
+              <option value="storage">storage</option>
4128
+              <option value="shield">shield</option>
4129
+              <option value="network">network</option>
4130
+              <option value="alert">alert</option>
4131
+            </select>
4132
+            <button type="button" id="tag-add">Save</button>
4133
+          </div>
4134
+          <div class="table-wrap">
4135
+            <table>
4136
+              <thead>
4137
+                <tr>
4138
+                  <th>Tag</th>
4139
+                  <th style="width: 110px">Color</th>
4140
+                  <th style="width: 150px">Icon</th>
4141
+                  <th style="width: 120px">Hosts</th>
4142
+                  <th style="width: 130px">Actions</th>
4143
+                </tr>
4144
+              </thead>
4145
+              <tbody id="tags"></tbody>
4146
+            </table>
4147
+          </div>
3658 4148
         </section>
3659 4149
       </section>
3660 4150
 
@@ -3757,7 +4247,7 @@ sub app_html {
3757 4247
   </div>
3758 4248
 
3759 4249
   <script>
3760
-    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
4250
+    let state = { hosts: [], vhosts: [], tags: [], observations: {}, certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
3761 4251
     let hostFormSnapshot = '';
3762 4252
     let hostFormBusy = false;
3763 4253
     let hostFormMode = 'new';
@@ -3780,13 +4270,18 @@ sub app_html {
3780 4270
       </div>
3781 4271
       <form id="host-form" class="grid">
3782 4272
         <input type="hidden" name="id">
3783
-        <label>FQDN<input name="fqdn" required></label>
4273
+        <label>FQDN<input name="fqdn" required list="host-fqdn-hints"></label>
3784 4274
         <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3785
-        <label>IP<input name="ip" required></label>
4275
+        <label>IP<input name="ip" required list="host-ip-hints"></label>
3786 4276
         <label class="span2"><span class="field-head"><span>Aliases</span><button type="button" id="host-add-alias-editor" class="host-alias-add" title="Add alias">+</button></span><textarea name="aliases"></textarea></label>
3787 4277
         <label>Roles<input name="roles"></label>
4278
+        <label>Tags<input name="tags" list="tag-hints"></label>
3788 4279
         <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3789 4280
         <label>Notes<input name="notes"></label>
4281
+        <div id="host-observation-hints" class="span2 observation-hints"></div>
4282
+        <datalist id="host-fqdn-hints"></datalist>
4283
+        <datalist id="host-ip-hints"></datalist>
4284
+        <datalist id="tag-hints"></datalist>
3790 4285
         <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3791 4286
       </form>`;
3792 4287
     const hostForm = hostFormShell.querySelector('#host-form');
@@ -3799,7 +4294,7 @@ sub app_html {
3799 4294
     const hostEditorRow = document.createElement('tr');
3800 4295
     hostEditorRow.className = 'host-inline-row';
3801 4296
     const hostEditorCell = document.createElement('td');
3802
-    hostEditorCell.colSpan = 8;
4297
+    hostEditorCell.colSpan = 9;
3803 4298
     hostEditorRow.appendChild(hostEditorCell);
3804 4299
     hostEditorCell.appendChild(hostFormShell);
3805 4300
     const PAGE_PATHS = {
@@ -3807,6 +4302,7 @@ sub app_html {
3807 4302
       '/overview': 'overview',
3808 4303
       '/hosts': 'hosts',
3809 4304
       '/vhosts': 'vhosts',
4305
+      '/tags': 'tags',
3810 4306
       '/dns': 'dns',
3811 4307
       '/work-orders': 'work-orders',
3812 4308
       '/ca': 'ca',
@@ -3865,6 +4361,20 @@ sub app_html {
3865 4361
       return body;
3866 4362
     }
3867 4363
 
4364
+    async function fetchText(path, options = {}) {
4365
+      const res = await fetch(path, options);
4366
+      const text = await res.text();
4367
+      if (!res.ok) {
4368
+        if (res.status === 401) {
4369
+          const error = authLostError();
4370
+          handleAuthLost(error.message);
4371
+          throw error;
4372
+        }
4373
+        throw new Error(text || res.statusText);
4374
+      }
4375
+      return text;
4376
+    }
4377
+
3868 4378
     function currentPage() {
3869 4379
       return PAGE_PATHS[window.location.pathname] || 'overview';
3870 4380
     }
@@ -3887,6 +4397,11 @@ sub app_html {
3887 4397
           if (!isAuthLost(e)) msg(e.message);
3888 4398
         });
3889 4399
       }
4400
+      if (state.authenticated && target === 'dns') {
4401
+        renderHostsYamlRaw().catch(e => {
4402
+          if (!isAuthLost(e)) msg(e.message);
4403
+        });
4404
+      }
3890 4405
     }
3891 4406
 
3892 4407
     function showLogin(errorText) {
@@ -3915,12 +4430,15 @@ sub app_html {
3915 4430
       const data = await api('/api/hosts');
3916 4431
       state.hosts = data.hosts || [];
3917 4432
       state.vhosts = data.vhosts || [];
4433
+      state.tags = data.tags || [];
4434
+      state.observations = data.observations || {};
3918 4435
       state.certificates = data.certificates || [];
3919 4436
       state.problems = data.problems || [];
3920 4437
       render(data);
3921 4438
       await renderCa();
3922 4439
       await renderWorkOrders();
3923 4440
       if (currentPage() === 'debug') await renderDebugDatabase();
4441
+      if (currentPage() === 'dns') await renderHostsYamlRaw();
3924 4442
     }
3925 4443
 
3926 4444
     function render(data) {
@@ -3939,8 +4457,226 @@ sub app_html {
3939 4457
       renderHosts();
3940 4458
       renderVhostEditor();
3941 4459
       renderVhosts();
4460
+      renderTags();
4461
+      renderObservationDatalists();
4462
+      renderVhostObservationHints();
4463
+    }
4464
+
4465
+    async function renderHostsYamlRaw() {
4466
+      if (!state.authenticated || !$('hosts-yaml-raw')) return;
4467
+      const text = await fetchText('/api/exports/hosts.yaml');
4468
+      $('hosts-yaml-raw').textContent = text;
4469
+      const lines = text ? text.split('\n').filter(Boolean).length : 0;
4470
+      $('hosts-yaml-stats').innerHTML = [
4471
+        ['lines', lines],
4472
+        ['hosts', state.hosts.length],
4473
+      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4474
+    }
4475
+
4476
+    function renderTags() {
4477
+      if (!$('tags')) return;
4478
+      const tags = (state.tags || []).slice().sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
4479
+      $('tag-stats').innerHTML = [
4480
+        ['tags', tags.length],
4481
+        ['associations', tags.reduce((total, tag) => total + Number(tag.host_count || 0), 0)],
4482
+      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4483
+      $('tags').innerHTML = tags.length ? tags.map(tag => {
4484
+        const id = tag.tag_id || tag.label || '';
4485
+        return `<tr data-tag-id="${escapeHtml(id)}">
4486
+          <td><input data-tag-label="${escapeHtml(id)}" value="${escapeHtml(tag.label || '')}"></td>
4487
+          <td><input type="color" data-tag-color="${escapeHtml(id)}" value="${escapeHtml(tag.color || '#647084')}"></td>
4488
+          <td>
4489
+            <select data-tag-icon="${escapeHtml(id)}">
4490
+              ${tagIconOptions(tag.icon || 'tag')}
4491
+            </select>
4492
+          </td>
4493
+          <td><span class="pill">${escapeHtml(String(tag.host_count || 0))}</span></td>
4494
+          <td><div class="tag-actions">
4495
+            <button type="button" data-tag-save="${escapeHtml(id)}">Save</button>
4496
+            <button type="button" class="danger" data-tag-delete="${escapeHtml(id)}">Delete</button>
4497
+          </div></td>
4498
+        </tr>`;
4499
+      }).join('') : '<tr><td colspan="5" class="muted">No tags yet.</td></tr>';
4500
+      document.querySelectorAll('[data-tag-save]').forEach(button => {
4501
+        button.addEventListener('click', () => saveTagRow(button.dataset.tagSave || '').catch(e => {
4502
+          if (!isAuthLost(e)) msg(e.message);
4503
+        }));
4504
+      });
4505
+      document.querySelectorAll('[data-tag-delete]').forEach(button => {
4506
+        button.addEventListener('click', () => deleteTagRow(button.dataset.tagDelete || '').catch(e => {
4507
+          if (!isAuthLost(e)) msg(e.message);
4508
+        }));
4509
+      });
4510
+    }
4511
+
4512
+    function tagIconOptions(selected) {
4513
+      return ['tag', 'server', 'storage', 'shield', 'network', 'alert']
4514
+        .map(icon => `<option value="${escapeHtml(icon)}"${icon === selected ? ' selected' : ''}>${escapeHtml(icon)}</option>`)
4515
+        .join('');
4516
+    }
4517
+
4518
+    function renderObservationDatalists() {
4519
+      const hints = observationHints();
4520
+      const fqdnOptions = unique(hints.map(hint => hint.candidate_fqdn).filter(Boolean));
4521
+      const ipOptions = unique(hints.map(hint => hint.ip_address).filter(Boolean));
4522
+      const vhostOptions = fqdnOptions;
4523
+      if ($('host-fqdn-hints')) $('host-fqdn-hints').innerHTML = fqdnOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4524
+      if ($('host-ip-hints')) $('host-ip-hints').innerHTML = ipOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4525
+      if ($('vhost-name-hints')) $('vhost-name-hints').innerHTML = vhostOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4526
+      if ($('tag-hints')) $('tag-hints').innerHTML = (state.tags || []).map(tag => `<option value="${escapeHtml(tag.label || '')}"></option>`).join('');
4527
+    }
4528
+
4529
+    async function addOrUpdateTag(label, color, icon) {
4530
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4531
+      await api('/api/tags/upsert', {
4532
+        method: 'POST',
4533
+        headers: { 'Content-Type': 'application/json' },
4534
+        body: JSON.stringify({ label, color, icon }),
4535
+      });
4536
+      msg(`tag ${label} saved`);
4537
+      await refresh();
3942 4538
     }
3943 4539
 
4540
+    async function saveTagRow(tagId) {
4541
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4542
+      const selector = attrSelectorValue(tagId);
4543
+      const label = document.querySelector(`[data-tag-label="${selector}"]`).value || '';
4544
+      const color = document.querySelector(`[data-tag-color="${selector}"]`).value || '#647084';
4545
+      const icon = document.querySelector(`[data-tag-icon="${selector}"]`).value || 'tag';
4546
+      await api('/api/tags/rename', {
4547
+        method: 'POST',
4548
+        headers: { 'Content-Type': 'application/json' },
4549
+        body: JSON.stringify({ tag_id: tagId, new_label: label, color, icon }),
4550
+      });
4551
+      msg(`tag ${label} saved`);
4552
+      await refresh();
4553
+    }
4554
+
4555
+    async function deleteTagRow(tagId) {
4556
+      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4557
+      if (!confirm(`Delete tag ${tagId} and remove it from hosts?`)) return;
4558
+      await api('/api/tags/delete', {
4559
+        method: 'POST',
4560
+        headers: { 'Content-Type': 'application/json' },
4561
+        body: JSON.stringify({ tag_id: tagId }),
4562
+      });
4563
+      msg(`tag ${tagId} deleted`);
4564
+      await refresh();
4565
+    }
4566
+
4567
+    function observationHints() {
4568
+      const dhcp = Array.isArray(state.observations.dhcp_leases) ? state.observations.dhcp_leases : [];
4569
+      const mdns = Array.isArray(state.observations.mdns_observations) ? state.observations.mdns_observations : [];
4570
+      return dhcp.concat(mdns).map(row => {
4571
+        const observed = String(row.observed_name || '').toLowerCase();
4572
+        const candidate = String(row.candidate_fqdn || candidateFqdnFromObserved(observed) || '').toLowerCase();
4573
+        return {
4574
+          source: row.source || '',
4575
+          observed_name: observed,
4576
+          candidate_fqdn: candidate,
4577
+          ip_address: row.ip_address || '',
4578
+          host_fqdn: row.host_fqdn || row.existing_host_fqdn || '',
4579
+          last_seen: row.last_seen || '',
4580
+        };
4581
+      }).filter(row => row.observed_name || row.ip_address || row.candidate_fqdn);
4582
+    }
4583
+
4584
+    function candidateFqdnFromObserved(name) {
4585
+      name = String(name || '').toLowerCase().replace(/\.$/, '');
4586
+      if (!name) return '';
4587
+      if (name.endsWith('.local')) name = name.slice(0, -6);
4588
+      if (!name) return '';
4589
+      if (name.endsWith('.madagascar.xdev.ro')) return name;
4590
+      if (!name.includes('.')) return `${name}.madagascar.xdev.ro`;
4591
+      return '';
4592
+    }
4593
+
4594
+    function matchingObservationHints(name, ip) {
4595
+      const fqdn = candidateFqdnFromObserved(name) || normalizeHostFqdn(name);
4596
+      const shortName = shortAliasForFqdn(fqdn) || String(name || '').toLowerCase().replace(/\.local$/, '');
4597
+      return observationHints().filter(hint => {
4598
+        const hintShort = shortAliasForFqdn(hint.candidate_fqdn) || String(hint.observed_name || '').replace(/\.local$/, '');
4599
+        return (fqdn && hint.candidate_fqdn === fqdn)
4600
+          || (shortName && hintShort === shortName)
4601
+          || (ip && hint.ip_address === ip);
4602
+      });
4603
+    }
4604
+
4605
+    function renderHostObservationHints() {
4606
+      const box = hostFormShell.querySelector('#host-observation-hints');
4607
+      if (!box) return;
4608
+      const fqdn = hostField('fqdn').value || '';
4609
+      const ip = hostField('ip').value || '';
4610
+      const matches = matchingObservationHints(fqdn, ip).slice(0, 3);
4611
+      box.innerHTML = matches.map(renderHostObservationCard).join('');
4612
+      box.querySelectorAll('[data-use-observation-ip]').forEach(button => {
4613
+        button.addEventListener('click', () => {
4614
+          hostField('ip').value = button.dataset.useObservationIp || '';
4615
+          hostField('ip').dispatchEvent(new Event('input', { bubbles: true }));
4616
+        });
4617
+      });
4618
+      box.querySelectorAll('[data-open-observation-host]').forEach(button => {
4619
+        button.addEventListener('click', () => {
4620
+          editHostByFqdn(button.dataset.openObservationHost || '').catch(e => {
4621
+            if (!isAuthLost(e)) msg(e.message);
4622
+          });
4623
+        });
4624
+      });
4625
+    }
4626
+
4627
+    function renderHostObservationCard(hint) {
4628
+      const host = hostByIp(hint.ip_address) || hostByFqdn(hint.host_fqdn);
4629
+      const actions = [
4630
+        hint.ip_address ? `<button type="button" data-use-observation-ip="${escapeHtml(hint.ip_address)}">Use IP</button>` : '',
4631
+        host ? `<button type="button" data-open-observation-host="${escapeHtml(host.fqdn || '')}">Open host</button>` : '',
4632
+      ].filter(Boolean).join('');
4633
+      return `<div class="observation-card">
4634
+        <div class="observation-card-main">
4635
+          <div class="observation-card-title">${escapeHtml(hint.observed_name || hint.candidate_fqdn || '')} ${hint.ip_address ? `-> ${escapeHtml(hint.ip_address)}` : ''}</div>
4636
+          <div>${escapeHtml(hint.source || 'observed')} ${hint.last_seen ? `seen ${escapeHtml(hint.last_seen)}` : ''}${host ? `, matches ${escapeHtml(host.fqdn || '')}` : ''}</div>
4637
+        </div>
4638
+        <div class="observation-card-actions">${actions}</div>
4639
+      </div>`;
4640
+    }
4641
+
4642
+    function renderVhostObservationHints() {
4643
+      const box = $('vhost-observation-hints');
4644
+      if (!box) return;
4645
+      const raw = $('vhost-new-name').value || '';
4646
+      const vhost = normalizeVhostInput(raw);
4647
+      const matches = matchingObservationHints(vhost, '').slice(0, 3);
4648
+      box.innerHTML = matches.map(renderVhostObservationCard).join('');
4649
+      box.querySelectorAll('[data-attach-observation-host]').forEach(button => {
4650
+        button.addEventListener('click', () => {
4651
+          $('vhost-new-host').value = button.dataset.attachObservationHost || '';
4652
+          msg(`vhost target set to ${button.dataset.attachObservationHost || ''}`);
4653
+        });
4654
+      });
4655
+      box.querySelectorAll('[data-create-host-from-observation]').forEach(button => {
4656
+        button.addEventListener('click', () => {
4657
+          newHostFromObservation(button.dataset.createHostFromObservation || '', button.dataset.observationIp || '').catch(e => {
4658
+            if (!isAuthLost(e)) msg(e.message);
4659
+          });
4660
+        });
4661
+      });
4662
+    }
4663
+
4664
+    function renderVhostObservationCard(hint) {
4665
+      const host = hostByIp(hint.ip_address) || hostByFqdn(hint.host_fqdn);
4666
+      const candidate = hint.candidate_fqdn || candidateFqdnFromObserved(hint.observed_name);
4667
+      const actions = host
4668
+        ? `<button type="button" data-attach-observation-host="${escapeHtml(host.fqdn || '')}">Attach to host</button>`
4669
+        : `<button type="button" data-create-host-from-observation="${escapeHtml(candidate)}" data-observation-ip="${escapeHtml(hint.ip_address || '')}">New host</button>`;
4670
+      return `<div class="observation-card">
4671
+        <div class="observation-card-main">
4672
+          <div class="observation-card-title">${escapeHtml(hint.observed_name || candidate)} ${hint.ip_address ? `-> ${escapeHtml(hint.ip_address)}` : ''}</div>
4673
+          <div>${host ? `existing host ${escapeHtml(host.fqdn || '')}` : 'observed only'}</div>
4674
+        </div>
4675
+        <div class="observation-card-actions">${actions}</div>
4676
+      </div>`;
4677
+    }
4678
+
4679
+
3944 4680
     async function renderCa() {
3945 4681
       try {
3946 4682
         const status = await api('/api/ca/status');
@@ -4242,7 +4978,7 @@ sub app_html {
4242 4978
           headers: { 'Content-Type': 'application/json' },
4243 4979
           body: JSON.stringify({ id, confirm: typed })
4244 4980
         });
4245
-        msg('work order confirmed; local-hosts.tsv written');
4981
+        msg('work order confirmed; resolver sync queued');
4246 4982
         await refresh();
4247 4983
       } catch (e) {
4248 4984
         if (isAuthLost(e)) return;
@@ -4264,6 +5000,7 @@ sub app_html {
4264 5000
             <td>${escapeHtml(h.ip || '')}</td>
4265 5001
             <td>${renderHostAliasCell(h)}</td>
4266 5002
             <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
5003
+            <td>${renderHostTags(h)}</td>
4267 5004
             <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
4268 5005
             <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4269 5006
             <td>${escapeHtml(h.status || '')}</td>
@@ -4320,6 +5057,21 @@ sub app_html {
4320 5057
       </div>`;
4321 5058
     }
4322 5059
 
5060
+    function renderHostTags(host) {
5061
+      const details = Array.isArray(host.tag_details) ? host.tag_details : [];
5062
+      const tags = details.length ? details : (host.tags || []).map(label => tagByLabel(label) || { label, color: '#647084', icon: 'tag' });
5063
+      return `<div class="tag-list">${tags.map(renderTagPill).join('')}</div>`;
5064
+    }
5065
+
5066
+    function renderTagPill(tag) {
5067
+      const color = validColor(tag.color) ? tag.color : '#647084';
5068
+      const icon = tag.icon || 'tag';
5069
+      const label = tag.label || '';
5070
+      return `<span class="pill tag-pill" title="${escapeHtml(icon)}">
5071
+        <span class="tag-dot" style="background:${escapeHtml(color)}"></span>${escapeHtml(label)}
5072
+      </span>`;
5073
+    }
5074
+
4323 5075
     function renderHostCertificateCell(host) {
4324 5076
       const cert = host.certificate || {};
4325 5077
       const certId = host.certificate_id || certificateIdOf(cert) || '';
@@ -4420,13 +5172,11 @@ sub app_html {
4420 5172
     }
4421 5173
 
4422 5174
     function renderVhostNameCell(row) {
4423
-      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4424 5175
       return `<div class="vhost-name-cell">
4425 5176
         <div class="vhost-name-main">
4426 5177
           <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4427 5178
           <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4428 5179
         </div>
4429
-        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4430 5180
       </div>`;
4431 5181
     }
4432 5182
 
@@ -4583,6 +5333,28 @@ sub app_html {
4583 5333
       return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4584 5334
     }
4585 5335
 
5336
+    function hostByIp(ip) {
5337
+      ip = String(ip || '').trim();
5338
+      return state.hosts.find(host => String(host.ip || '').trim() === ip) || null;
5339
+    }
5340
+
5341
+    function tagByLabel(label) {
5342
+      label = normalizeTagLabel(label);
5343
+      return (state.tags || []).find(tag => normalizeTagLabel(tag.label || '') === label) || null;
5344
+    }
5345
+
5346
+    function normalizeTagLabel(label) {
5347
+      return String(label || '').trim().toLowerCase().replace(/[^a-z0-9_. -]+/g, '-').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
5348
+    }
5349
+
5350
+    function validColor(value) {
5351
+      return /^#[0-9a-f]{6}$/i.test(String(value || ''));
5352
+    }
5353
+
5354
+    function unique(values) {
5355
+      return Array.from(new Set(values.filter(Boolean)));
5356
+    }
5357
+
4586 5358
     function hostUpsertPayload(host, overrides = {}) {
4587 5359
       const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4588 5360
       const payload = {
@@ -4592,6 +5364,7 @@ sub app_html {
4592 5364
         ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4593 5365
         aliases,
4594 5366
         roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
5367
+        tags: Array.isArray(overrides.tags) ? overrides.tags : (host.tags || []),
4595 5368
         sources: [],
4596 5369
         monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4597 5370
         notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
@@ -4749,7 +5522,8 @@ sub app_html {
4749 5522
       if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4750 5523
       const nameInput = $('vhost-new-name');
4751 5524
       const hostSelect = $('vhost-new-host');
4752
-      const vhost = (nameInput.value || '').trim().toLowerCase();
5525
+      const vhost = normalizeVhostInput(nameInput.value || '');
5526
+      nameInput.value = vhost;
4753 5527
       const hostFqdn = hostSelect.value || '';
4754 5528
       if (!vhost || !hostFqdn) {
4755 5529
         msg('completeaza vhost si host');
@@ -4793,6 +5567,12 @@ sub app_html {
4793 5567
       return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4794 5568
     }
4795 5569
 
5570
+    function normalizeVhostInput(name) {
5571
+      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
5572
+      if (name && !name.includes('.')) return `${name}.madagascar.xdev.ro`;
5573
+      return name;
5574
+    }
5575
+
4796 5576
     function vhostErrorMessage(error) {
4797 5577
       const code = error && error.code ? error.code : '';
4798 5578
       if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
@@ -4868,9 +5648,16 @@ sub app_html {
4868 5648
       for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4869 5649
       hostField('aliases').value = (host.aliases || []).join('\n');
4870 5650
       hostField('roles').value = (host.roles || []).join(' ');
5651
+      hostField('tags').value = (host.tags || []).join(' ');
4871 5652
       activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
4872 5653
     }
4873 5654
 
5655
+    async function editHostByFqdn(fqdn) {
5656
+      const host = hostByFqdn(fqdn);
5657
+      if (!host) return;
5658
+      await editHost(host.id);
5659
+    }
5660
+
4874 5661
     async function newHost() {
4875 5662
       if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
4876 5663
       if (!canSwitchHostEditor('__new__')) return;
@@ -4879,6 +5666,18 @@ sub app_html {
4879 5666
       activateHostForm('New host', 'new', '__new__', 'fqdn');
4880 5667
     }
4881 5668
 
5669
+    async function newHostFromObservation(fqdn, ip) {
5670
+      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
5671
+      if (!canSwitchHostEditor('__new__')) return;
5672
+      resetHostForm(true);
5673
+      hostField('id').value = '';
5674
+      hostField('fqdn').value = normalizeHostFqdn(fqdn || '');
5675
+      hostField('ip').value = ip || '';
5676
+      syncHostFormId();
5677
+      activateHostForm('New host', 'new', '__new__', ip ? 'tags' : 'ip');
5678
+      renderHostObservationHints();
5679
+    }
5680
+
4882 5681
     function activateHostForm(title, mode, target, focusField = 'fqdn', scroll = true) {
4883 5682
       hostFormMode = mode || 'new';
4884 5683
       hostEditorTarget = target || '';
@@ -4886,6 +5685,7 @@ sub app_html {
4886 5685
       syncHostFormActions();
4887 5686
       renderHosts();
4888 5687
       hostFormSnapshot = hostFormState();
5688
+      renderHostObservationHints();
4889 5689
       if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
4890 5690
       hostField(focusField).focus();
4891 5691
     }
@@ -4924,7 +5724,7 @@ sub app_html {
4924 5724
         hostFormShell.hidden = true;
4925 5725
         return;
4926 5726
       }
4927
-      hostEditorCell.colSpan = 8;
5727
+      hostEditorCell.colSpan = 9;
4928 5728
       const tbody = $('hosts');
4929 5729
       if (!tbody) return;
4930 5730
       if (hostEditorTarget === '__new__') {
@@ -4980,6 +5780,10 @@ sub app_html {
4980 5780
       return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
4981 5781
     }
4982 5782
 
5783
+    function attrSelectorValue(value) {
5784
+      return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
5785
+    }
5786
+
4983 5787
     const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4984 5788
 
4985 5789
     // OTP digit boxes — auto-advance, backspace, paste
@@ -5154,6 +5958,7 @@ sub app_html {
5154 5958
     }));
5155 5959
     $('filter').addEventListener('input', renderHosts);
5156 5960
     $('vhost-filter').addEventListener('input', renderVhosts);
5961
+    $('vhost-new-name').addEventListener('input', renderVhostObservationHints);
5157 5962
     $('vhost-add').addEventListener('click', () => {
5158 5963
       addVhostInline().catch(e => {
5159 5964
         if (!isAuthLost(e)) msg(e.message);
@@ -5204,6 +6009,7 @@ sub app_html {
5204 6009
     hostForm.addEventListener('input', (event) => {
5205 6010
       if (hostFormMode === 'new' && event.target && event.target.name === 'fqdn') syncHostFormId();
5206 6011
       if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
6012
+      if (event.target && (event.target.name === 'fqdn' || event.target.name === 'ip')) renderHostObservationHints();
5207 6013
     });
5208 6014
 
5209 6015
     deleteHostButton.addEventListener('click', async () => {
@@ -5228,11 +6034,17 @@ sub app_html {
5228 6034
     resetHostForm(true);
5229 6035
     closeHostForm(true);
5230 6036
 
5231
-    $('write-tsv').addEventListener('click', async () => {
5232
-      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
6037
+    $('tag-add').addEventListener('click', () => {
6038
+      addOrUpdateTag($('tag-new-label').value || '', $('tag-new-color').value || '#647084', $('tag-new-icon').value || 'tag').catch(e => {
6039
+        if (!isAuthLost(e)) msg(e.message);
6040
+      });
6041
+    });
6042
+
6043
+    $('publish-dns').addEventListener('click', async () => {
6044
+      if (!confirm('Queue resolver sync from the runtime registry?')) return;
5233 6045
       try {
5234
-        await api('/api/render/local-hosts-tsv', { method: 'POST' });
5235
-        msg('local-hosts.tsv written');
6046
+        await api('/api/dns/publish', { method: 'POST' });
6047
+        msg('resolver sync queued');
5236 6048
       } catch (e) {
5237 6049
         if (!isAuthLost(e)) msg(e.message);
5238 6050
       }
+9 -9
scripts/sync_local_hosts.sh
@@ -21,7 +21,7 @@ usage() {
21 21
 Usage: $0 [--apply] [--verify] [--target all|jumper|as01] [--source jumper|local]
22 22
 
23 23
 Default mode is dry-run. Use --apply to change remote resolvers.
24
-The default manifest source is the runtime SQLite database on jumper.
24
+Resolver records are generated at runtime from the SQLite database on jumper.
25 25
 
26 26
 Examples:
27 27
   $0
@@ -69,7 +69,7 @@ fi
69 69
 WORK_DIR="$(mktemp -d)"
70 70
 trap 'rm -rf "$WORK_DIR"' EXIT
71 71
 
72
-MANIFEST_FILE="$WORK_DIR/local-hosts.tsv"
72
+RESOLVER_RECORDS="$WORK_DIR/resolver-records.tsv"
73 73
 HOSTS_ROWS="$WORK_DIR/hosts.rows"
74 74
 CLOAK_ROWS="$WORK_DIR/cloak.rows"
75 75
 NAMES_FILE="$WORK_DIR/names.txt"
@@ -104,22 +104,22 @@ run_jumper_payload() {
104 104
     fi
105 105
 }
106 106
 
107
-fetch_manifest() {
107
+fetch_resolver_records() {
108 108
     case "$SOURCE" in
109 109
         local)
110
-            perl "$HOST_MANAGER" --print-local-hosts-tsv
110
+            perl "$HOST_MANAGER" --print-resolver-records
111 111
             ;;
112 112
         jumper)
113 113
             if is_local_jumper; then
114
-                perl "$HOST_MANAGER" --print-local-hosts-tsv
114
+                perl "$HOST_MANAGER" --print-resolver-records
115 115
             else
116
-                ssh "$JUMPER_HOST" "perl /usr/local/xdev-host-manager/scripts/host_manager.pl --print-local-hosts-tsv"
116
+                ssh "$JUMPER_HOST" "perl /usr/local/xdev-host-manager/scripts/host_manager.pl --print-resolver-records"
117 117
             fi
118 118
             ;;
119
-    esac > "$MANIFEST_FILE"
119
+    esac > "$RESOLVER_RECORDS"
120 120
 }
121 121
 
122
-fetch_manifest
122
+fetch_resolver_records
123 123
 
124 124
 while IFS= read -r line || [[ -n "$line" ]]; do
125 125
     [[ -z "${line//[[:space:]]/}" ]] && continue
@@ -173,7 +173,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
173 173
             printf '/ip dns static add name="%s" type=A address=%s comment="xdev-local managed"\n' "$ros_name" "$ros_ip"
174 174
         } >> "$MIKROTIK_RSC"
175 175
     done
176
-done < "$MANIFEST_FILE"
176
+done < "$RESOLVER_RECORDS"
177 177
 
178 178
 sort -u "$NAMES_FILE" -o "$NAMES_FILE"
179 179
 printf '/ip dns cache flush\n' >> "$MIKROTIK_RSC"