@@ -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 |
``` |
@@ -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. | |
@@ -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 |
+``` |
|
@@ -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` |
@@ -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 |
+``` |
|
@@ -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) | |
|
@@ -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 |
|
@@ -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. |
|
@@ -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 |
|
@@ -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. |
|
@@ -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 |
|
@@ -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 |
|
@@ -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." |
|
@@ -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 |
|
@@ -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 |
|
@@ -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. |
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 |
} |
@@ -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" |