Showing 12 changed files with 1968 additions and 0 deletions
+118 -0
.doc/host-manager.md
@@ -0,0 +1,118 @@
1
+# Host Manager MVP
2
+
3
+Host Manager este o aplicație web locală, Perl-only, pentru registrul de hosturi Madagascar. Nu folosește npm, pip sau pachete CPAN instalate direct pe host.
4
+
5
+## Politica de dependențe
6
+
7
+Perl-ul livrat de distribuție este acceptat ca bază de runtime. Modulele Perl incluse în distribuție sau în core pot fi folosite direct.
8
+
9
+Pachetele CPAN nu se instalează direct pe host cu `cpan`, `cpanm` sau mecanisme similare. Dacă aplicația are nevoie de un modul CPAN, modulul trebuie făcut disponibil prin repo-ul local după audit. Este acceptabilă o versiune mai veche/stabilă din repo-ul local; nu urmărim neapărat ultima versiune upstream.
10
+
11
+MVP-ul curent nu are dependențe CPAN externe.
12
+
13
+## Rol
14
+
15
+`config/hosts.yaml` este registrul editabil și trebuie menținut în git. Aplicația îl expune read-only pentru servicii și, cu autentificare OTP, permite modificări controlate în working tree.
16
+
17
+Git rămâne mecanismul de audit, istoric și rollback. Aplicația nu înlocuiește repo-ul și nu devine o bază de date separată.
18
+
19
+Endpoint-uri publice read-only:
20
+
21
+- `/download/hosts.yaml` — registrul complet
22
+- `/download/local-hosts.tsv` — manifest DNS local derivat
23
+- `/download/monitoring.json` — listă pentru monitorizare
24
+- `/api/hosts` — JSON cu hosturi și probleme detectate
25
+
26
+Endpoint-uri cu OTP:
27
+
28
+- `POST /api/hosts/upsert`
29
+- `POST /api/hosts/delete`
30
+- `POST /api/render/local-hosts-tsv`
31
+
32
+## Pornire locală
33
+
34
+```bash
35
+HOST_MANAGER_TOTP_SECRET="BASE32_SECRET_AICI" \
36
+perl scripts/host_manager.pl --bind 127.0.0.1 --port 8088
37
+```
38
+
39
+Deschide:
40
+
41
+```text
42
+http://127.0.0.1:8088/
43
+```
44
+
45
+Implicit serverul ascultă doar pe loopback. Pe jumper, publicarea se face prin nginx, ca vhost separat, cu proxy către `127.0.0.1:8088`.
46
+
47
+Vhost-ul implicit propus este:
48
+
49
+```text
50
+hosts.madagascar.xdev.ro
51
+```
52
+
53
+Configurațiile de deployment sunt în `deploy/jumper/`.
54
+
55
+Instanța de pe jumper este instalată în `/usr/local/xdev-host-manager` și publicată prin:
56
+
57
+```text
58
+http://hosts.madagascar.xdev.ro/
59
+```
60
+
61
+Secretul TOTP nu este în repo. Pentru bootstrap, citește URI-ul root-only de pe jumper:
62
+
63
+```bash
64
+ssh is-vpn-gw 'cat /etc/xdev/host-manager.totp-uri'
65
+```
66
+
67
+## OTP
68
+
69
+`HOST_MANAGER_TOTP_SECRET` trebuie să fie un secret Base32 compatibil TOTP. Fără această variabilă, download-urile read-only funcționează, dar login-ul și scrierile nu.
70
+
71
+Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de secrete sau systemd environment file, se definește separat pe host.
72
+
73
+## Flux
74
+
75
+1. Hosturile se editează în aplicație sau direct în `config/hosts.yaml`.
76
+2. Serviciile externe descarcă `/download/hosts.yaml`, `/download/local-hosts.tsv` sau `/download/monitoring.json`.
77
+3. Pentru DNS local, butonul `Write local-hosts.tsv` regenerează `config/local-hosts.tsv`.
78
+4. Sincronizarea efectivă către is-vpn-gw și as01 rămâne:
79
+
80
+```bash
81
+./scripts/sync_local_hosts.sh --apply --verify
82
+```
83
+
84
+## Git și managementul cheilor
85
+
86
+Varianta preferată pentru servicii automate este să citească `config/hosts.yaml` din git, nu să depindă direct de sesiunea web. Serviciile care sincronizează DNS, monitorizare sau inventare primesc chei dedicate, cu acces minim.
87
+
88
+Reguli:
89
+
90
+- Fiecare host/serviciu automat are propria cheie SSH, fără reutilizare între roluri.
91
+- Cheile de consum sunt read-only pentru repo.
92
+- Cheile care pot scrie în repo sunt rare, separate și folosite doar de componenta de management.
93
+- Cheile nu se comit în repo și nu se pun în `hosts.yaml`.
94
+- Pentru deployment pe hosturi, se preferă chei restrânse la comanda necesară sau deploy keys read-only unde platforma git permite.
95
+- Pull-ul automat trebuie să valideze fișierele înainte de aplicare; de exemplu `perl -c scripts/host_manager.pl` și `./scripts/sync_local_hosts.sh --verify` după generarea DNS.
96
+
97
+Modelul recomandat:
98
+
99
+```text
100
+git repo
101
+  config/hosts.yaml        sursă versionată
102
+  config/local-hosts.tsv   manifest generat/versionat pentru DNS local
103
+
104
+jumper
105
+  host-manager             editează working tree cu OTP
106
+  sync_local_hosts.sh      aplică DNS după review/verificare
107
+
108
+servicii consumatoare
109
+  git pull read-only       citesc hosts.yaml/monitoring.json/local-hosts.tsv
110
+```
111
+
112
+Pentru etapa MVP, aplicația nu face commit/push automat. După o modificare, schimbarea rămâne vizibilă în working tree și se comite explicit după review. Automatizarea commit/push poate fi adăugată ulterior, dar numai cu cheie separată și reguli clare de semnare/audit.
113
+
114
+## Limitări MVP
115
+
116
+- Parserul YAML acceptă schema strictă generată de aplicație, nu YAML arbitrar.
117
+- Conflict engine-ul verifică doar consistența locală din `hosts.yaml`.
118
+- DHCP, mDNS, `hosts-local.yaml` și `madagascar.json` sunt încă surse pentru audit manual sau pentru următorul collector.
+131 -0
.doc/local-hosts.md
@@ -0,0 +1,131 @@
1
+# Adăugare hosturi locale — madagascar network
2
+
3
+Rețeaua madagascar folosește un **DNS intern dual**: is-vpn-gw (192.168.2.100) ca resolver principal și as01 (192.168.2.2) ca fallback. Un hostname nou trebuie adăugat în **ambele locuri**.
4
+
5
+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`.
6
+
7
+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.
8
+
9
+## De ce dual?
10
+
11
+is-vpn-gw este sus pe "lista de sacrificiu" la power outage — poate fi oprit deliberat. Fără intrările de pe as01, clienții nu pot rezolva hostname-urile interne când jumper e oprit.
12
+
13
+## Unde se adaugă
14
+
15
+| Loc | Fișier | Rol |
16
+|-----|--------|-----|
17
+| is-vpn-gw `/etc/hosts` | resolver principal, precedență maximă față de dnscrypt-proxy | Funcționează și când dnscrypt-proxy e down |
18
+| is-vpn-gw `/etc/dnscrypt-proxy/cloaking-rules.txt` | servit clienților LAN prin dnscrypt-proxy | Clienții care cer DNS la 192.168.2.100 primesc IP-ul intern |
19
+| as01 `/ip dns static` (MikroTik) | fallback când is-vpn-gw e oprit | as01 servește direct din cache static propriu |
20
+
21
+Zona publică poate avea nume Madagascar care ajung la IP-ul public `89.32.222.226`. Pentru LAN, fiecare nume real care există și local trebuie să aibă override exact în ambele resolvere, altfel va cădea în DNS public și va ajunge greșit la `.226`.
22
+
23
+Implementarea versionată este:
24
+
25
+| Fișier | Rol |
26
+|--------|-----|
27
+| `config/local-hosts.tsv` | sursa unică pentru IP-uri, hostname-uri și aliasuri locale |
28
+| `scripts/sync_local_hosts.sh` | generează și sincronizează `/etc/hosts`, `cloaking-rules.txt` și `/ip dns static` |
29
+
30
+## Ierarhia surselor
31
+
32
+Când inventarele se contrazic, ordinea de încredere este:
33
+
34
+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.
35
+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`.
36
+3. `config/local-hosts.tsv` — manifestul DNS local publicat pe is-vpn-gw și as01. Acesta trebuie să fie derivat sau validat din DHCP plus topologia clusterului, nu folosit ca sursă primară de alocare IP.
37
+4. `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.
38
+5. 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`.
39
+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.
40
+
41
+Reguli de împăcare:
42
+
43
+- Pentru un IP de LAN, DHCP câștigă în fața oricărei valori hardcodate în inventare.
44
+- Pentru un IP de serviciu non-LAN, `madagascar.json` câștigă dacă explică explicit interfața sau rolul.
45
+- Pentru VM-uri, regula `vmid -> 192.168.2.vmid` este convenție de propunere și audit, nu autoritate. Rezervarea DHCP finală decide.
46
+- 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.
47
+- Dacă o sursă observată există doar în mDNS sau doar în lease-uri dinamice, se raportează ca propunere, nu se sincronizează automat în DNS.
48
+
49
+Pe `is-vpn-gw`, LAN-ul trebuie servit direct de `dnscrypt-proxy` pe `192.168.2.100:53`. `systemd-resolved` rămâne doar stub local și trebuie să aibă `ReadEtcHosts=no` plus upstream unic `DNS=127.0.0.1:5300`. Altfel fie publică intrările din `/etc/hosts` către clienții LAN, fie ocolește cloaking-rules și întreabă DNS public direct. Scriptul de sync verifică și aplică aceste setări.
50
+
51
+`dnscrypt-proxy` folosește blocklist; `google.com` și `www.google.com` sunt allowlist-uite explicit ca să nu fie întoarse ca `0.0.0.0`.
52
+
53
+Rulează întâi dry-run:
54
+
55
+```bash
56
+./scripts/sync_local_hosts.sh
57
+```
58
+
59
+Aplică și verifică:
60
+
61
+```bash
62
+./scripts/sync_local_hosts.sh --apply --verify
63
+```
64
+
65
+## Pași pentru un hostname nou
66
+
67
+```bash
68
+# 1. Adaugă hostul în config/local-hosts.tsv
69
+# Format: hosts_ip<TAB>dns_ip<TAB>name alias...
70
+192.168.2.XXX   192.168.2.XXX   host.madagascar.xdev.ro host
71
+
72
+# 2. Aplică pe ambele resolvere
73
+./scripts/sync_local_hosts.sh --apply --verify
74
+
75
+# 3. Verificare manuală, dacă e nevoie
76
+dig @192.168.2.100 host.madagascar.xdev.ro +short   # via is-vpn-gw
77
+dig @192.168.2.2   host.madagascar.xdev.ro +short   # via as01
78
+
79
+# 4. Verificare negativă: numele inexistente nu trebuie să se rezolve
80
+dig @192.168.2.100 nohost.madagascar.xdev.ro +short
81
+dig @192.168.2.2   nohost.madagascar.xdev.ro +short
82
+```
83
+
84
+Comenzile negative de mai sus trebuie să întoarcă output gol, iar cu `+comments` statusul trebuie să fie `NXDOMAIN`.
85
+
86
+## Hosturi speciale — excepții
87
+
88
+| Hostname | /etc/hosts pe is-vpn-gw | cloaking-rules | Motiv |
89
+|----------|------------------------|----------------|-------|
90
+| `mazeri.madagascar.xdev.ro` | 192.168.2.102 | 192.168.2.102 | Mașina Debian la .102, separată de is-vpn-gw chiar dacă Exim folosește acest hostname |
91
+| `jumper.madagascar.xdev.ro` | 127.0.0.1 | 192.168.2.100 | is-vpn-gw se referă la sine prin loopback (necesar pentru Exim local delivery); clienții LAN primesc IP-ul real |
92
+
93
+## Hosturi existente
94
+
95
+| Hostname | IP |
96
+|----------|----|
97
+| `baobab.madagascar.xdev.ro` | 192.168.10.91 |
98
+| `mazeri.madagascar.xdev.ro` | 192.168.2.102 |
99
+| `jumper.madagascar.xdev.ro` | 192.168.2.100 (127.0.0.1 local) |
100
+| `hosts.madagascar.xdev.ro` | 192.168.2.100 (vhost nginx pe jumper) |
101
+| `zabbix.madagascar.xdev.ro` | 192.168.2.107 |
102
+| `toltec.madagascar.xdev.ro` | 192.168.2.103 |
103
+| `ebony.madagascar.xdev.ro` | 192.168.10.92 |
104
+| `tapia.madagascar.xdev.ro` | 192.168.10.93 |
105
+| `autonas01.madagascar.xdev.ro` | 192.168.10.21 |
106
+| `autonas02.madagascar.xdev.ro` | 192.168.10.22 |
107
+| `anjothibe.madagascar.xdev.ro` | 192.168.2.95 |
108
+| `andrafiabe.madagascar.xdev.ro` | 192.168.2.96 |
109
+| `pmx.baobab.madagascar.xdev.ro` | 192.168.10.91 |
110
+| `pmx.ebony.madagascar.xdev.ro` | 192.168.10.92 |
111
+| `pmx.tapia.madagascar.xdev.ro` | 192.168.10.93 |
112
+| `pbs.anjothibe.madagascar.xdev.ro` | 192.168.2.95 |
113
+| `pbs.andrafiabe.madagascar.xdev.ro` | 192.168.2.96 |
114
+
115
+## Verificări obligatorii după modificări
116
+
117
+```bash
118
+# Host real: trebuie să întoarcă IP intern pe ambele resolvere
119
+dig @192.168.2.100 baobab.madagascar.xdev.ro +short
120
+dig @192.168.2.2   baobab.madagascar.xdev.ro +short
121
+
122
+# Host inexistent: trebuie să rămână nerezolvat local
123
+dig @192.168.2.100 nohost.madagascar.xdev.ro +noall +comments
124
+dig @192.168.2.2   nohost.madagascar.xdev.ro +noall +comments
125
+```
126
+
127
+## DNS public (zones/xdev.ro.zone)
128
+
129
+Hostname-urile interne **nu se adaugă** în zona publică. Excepție: dacă un hostname intern are și un port forward public, se adaugă în zona publică numai pentru acces extern și trebuie shadow-uit local prin override exact.
130
+
131
+> RFC 2181: un MX record nu poate pointa la un CNAME. Dacă adaugi un hostname care va primi MX, trebuie să fie A record în zona publică — vezi cazul `mazeri`.
+7 -0
.gitignore
@@ -0,0 +1,7 @@
1
+/backups/
2
+*.log
3
+*.tmp
4
+*.temp
5
+*~
6
+.DS_Store
7
+config/host-manager.env
+20 -0
README.md
@@ -0,0 +1,20 @@
1
+# Xdev Host Manager
2
+
3
+Local host registry and management UI for the Madagascar network.
4
+
5
+This project lives on jumper and is the local source for:
6
+
7
+- `config/hosts.yaml` - git-versioned host registry
8
+- `config/local-hosts.tsv` - DNS manifest exported for local resolvers
9
+- `scripts/host_manager.pl` - Perl-only web app
10
+- `scripts/sync_local_hosts.sh` - local DNS sync to is-vpn-gw and as01
11
+
12
+The public `xdev.ro` zone is maintained in the separate DNS public-zone repository.
13
+
14
+Runtime path:
15
+
16
+```text
17
+/usr/local/xdev-host-manager
18
+```
19
+
20
+Secrets live outside git in `/etc/xdev/host-manager.env`.
+178 -0
config/hosts.yaml
@@ -0,0 +1,178 @@
1
+version: 1
2
+updated_at: "2026-06-05T00:00:00Z"
3
+policy:
4
+  ip_authority: "dhcp"
5
+  topology_authority: "madagascar.json"
6
+  dns_manifest: "config/local-hosts.tsv"
7
+  storage_authority: "git"
8
+  consumer_access: "read-only deploy keys"
9
+hosts:
10
+  - id: "baobab"
11
+    status: "active"
12
+    hosts_ip: "192.168.10.91"
13
+    dns_ip: "192.168.10.91"
14
+    names:
15
+      - "baobab.madagascar.xdev.ro"
16
+      - "pmx.baobab.madagascar.xdev.ro"
17
+      - "baobab"
18
+      - "pmx.baobab"
19
+    roles:
20
+      - "proxmox"
21
+      - "pmx"
22
+    sources:
23
+      - "local-hosts.tsv"
24
+      - "madagascar.json"
25
+    monitoring: "pending"
26
+    notes: "Service DNS uses thunderbridge."
27
+  - id: "ebony"
28
+    status: "active"
29
+    hosts_ip: "192.168.10.92"
30
+    dns_ip: "192.168.10.92"
31
+    names:
32
+      - "ebony.madagascar.xdev.ro"
33
+      - "pmx.ebony.madagascar.xdev.ro"
34
+      - "ebony"
35
+      - "pmx.ebony"
36
+    roles:
37
+      - "proxmox"
38
+      - "pmx"
39
+    sources:
40
+      - "local-hosts.tsv"
41
+      - "madagascar.json"
42
+    monitoring: "pending"
43
+    notes: "Service DNS uses thunderbridge."
44
+  - id: "tapia"
45
+    status: "active"
46
+    hosts_ip: "192.168.10.93"
47
+    dns_ip: "192.168.10.93"
48
+    names:
49
+      - "tapia.madagascar.xdev.ro"
50
+      - "pmx.tapia.madagascar.xdev.ro"
51
+      - "tapia"
52
+      - "pmx.tapia"
53
+    roles:
54
+      - "proxmox"
55
+      - "pmx"
56
+    sources:
57
+      - "local-hosts.tsv"
58
+      - "madagascar.json"
59
+    monitoring: "pending"
60
+    notes: "Service DNS uses thunderbridge."
61
+  - id: "autonas01"
62
+    status: "active"
63
+    hosts_ip: "192.168.10.21"
64
+    dns_ip: "192.168.10.21"
65
+    names:
66
+      - "autonas01.madagascar.xdev.ro"
67
+      - "autonas01"
68
+    roles:
69
+      - "storage"
70
+    sources:
71
+      - "local-hosts.tsv"
72
+    monitoring: "pending"
73
+    notes: ""
74
+  - id: "autonas02"
75
+    status: "active"
76
+    hosts_ip: "192.168.10.22"
77
+    dns_ip: "192.168.10.22"
78
+    names:
79
+      - "autonas02.madagascar.xdev.ro"
80
+      - "autonas02"
81
+    roles:
82
+      - "storage"
83
+    sources:
84
+      - "local-hosts.tsv"
85
+    monitoring: "pending"
86
+    notes: ""
87
+  - id: "anjothibe"
88
+    status: "active"
89
+    hosts_ip: "192.168.2.95"
90
+    dns_ip: "192.168.2.95"
91
+    names:
92
+      - "anjothibe.madagascar.xdev.ro"
93
+      - "pbs.anjothibe.madagascar.xdev.ro"
94
+      - "anjothibe"
95
+      - "pbs.anjothibe"
96
+    roles:
97
+      - "pbs"
98
+      - "backup"
99
+    sources:
100
+      - "local-hosts.tsv"
101
+      - "madagascar.json"
102
+    monitoring: "pending"
103
+    notes: ""
104
+  - id: "andrafiabe"
105
+    status: "active"
106
+    hosts_ip: "192.168.2.96"
107
+    dns_ip: "192.168.2.96"
108
+    names:
109
+      - "andrafiabe.madagascar.xdev.ro"
110
+      - "pbs.andrafiabe.madagascar.xdev.ro"
111
+      - "andrafiabe"
112
+      - "pbs.andrafiabe"
113
+    roles:
114
+      - "pbs"
115
+      - "backup"
116
+    sources:
117
+      - "local-hosts.tsv"
118
+      - "madagascar.json"
119
+    monitoring: "pending"
120
+    notes: ""
121
+  - id: "mazeri"
122
+    status: "active"
123
+    hosts_ip: "192.168.2.102"
124
+    dns_ip: "192.168.2.102"
125
+    names:
126
+      - "mazeri.madagascar.xdev.ro"
127
+      - "mazeri"
128
+    roles:
129
+      - "service"
130
+    sources:
131
+      - "local-hosts.tsv"
132
+      - "hosts-local.yaml"
133
+    monitoring: "pending"
134
+    notes: ""
135
+  - id: "toltec"
136
+    status: "active"
137
+    hosts_ip: "192.168.2.103"
138
+    dns_ip: "192.168.2.103"
139
+    names:
140
+      - "toltec.madagascar.xdev.ro"
141
+      - "toltec"
142
+    roles:
143
+      - "service"
144
+    sources:
145
+      - "local-hosts.tsv"
146
+      - "hosts-local.yaml"
147
+    monitoring: "pending"
148
+    notes: ""
149
+  - id: "zabbix"
150
+    status: "active"
151
+    hosts_ip: "192.168.2.107"
152
+    dns_ip: "192.168.2.107"
153
+    names:
154
+      - "zabbix.madagascar.xdev.ro"
155
+      - "zabbix"
156
+    roles:
157
+      - "monitoring"
158
+    sources:
159
+      - "local-hosts.tsv"
160
+      - "hosts-local.yaml"
161
+    monitoring: "enabled"
162
+    notes: ""
163
+  - id: "jumper"
164
+    status: "active"
165
+    hosts_ip: "127.0.0.1"
166
+    dns_ip: "192.168.2.100"
167
+    names:
168
+      - "jumper.madagascar.xdev.ro"
169
+      - "hosts.madagascar.xdev.ro"
170
+      - "jumper"
171
+    roles:
172
+      - "entrypoint"
173
+      - "dns"
174
+    sources:
175
+      - "local-hosts.tsv"
176
+      - "hosts-local.yaml"
177
+    monitoring: "enabled"
178
+    notes: "Loopback only for local delivery on is-vpn-gw; LAN DNS gets 192.168.2.100."
+24 -0
config/local-hosts.tsv
@@ -0,0 +1,24 @@
1
+# Local DNS manifest for the madagascar network.
2
+#
3
+# Format:
4
+# hosts_ip<TAB>dns_ip<TAB>name [aliases...]
5
+#
6
+# hosts_ip is written to /etc/hosts on is-vpn-gw.
7
+# dns_ip is written to dnscrypt-proxy cloaking rules and MikroTik static DNS.
8
+# Use different values only for host-local exceptions such as jumper.
9
+#
10
+# Priority rule:
11
+# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.
12
+# - madagascar.json is canonical for cluster roles and service interfaces.
13
+# - This file publishes approved local DNS records derived from those sources.
14
+192.168.10.91	192.168.10.91	baobab.madagascar.xdev.ro pmx.baobab.madagascar.xdev.ro baobab pmx.baobab
15
+192.168.10.92	192.168.10.92	ebony.madagascar.xdev.ro pmx.ebony.madagascar.xdev.ro ebony pmx.ebony
16
+192.168.10.93	192.168.10.93	tapia.madagascar.xdev.ro pmx.tapia.madagascar.xdev.ro tapia pmx.tapia
17
+192.168.10.21	192.168.10.21	autonas01.madagascar.xdev.ro autonas01
18
+192.168.10.22	192.168.10.22	autonas02.madagascar.xdev.ro autonas02
19
+192.168.2.95	192.168.2.95	anjothibe.madagascar.xdev.ro pbs.anjothibe.madagascar.xdev.ro anjothibe pbs.anjothibe
20
+192.168.2.96	192.168.2.96	andrafiabe.madagascar.xdev.ro pbs.andrafiabe.madagascar.xdev.ro andrafiabe pbs.andrafiabe
21
+192.168.2.102	192.168.2.102	mazeri.madagascar.xdev.ro mazeri
22
+192.168.2.103	192.168.2.103	toltec.madagascar.xdev.ro toltec
23
+192.168.2.107	192.168.2.107	zabbix.madagascar.xdev.ro zabbix
24
+127.0.0.1	192.168.2.100	jumper.madagascar.xdev.ro hosts.madagascar.xdev.ro jumper
+88 -0
deploy/jumper/README.md
@@ -0,0 +1,88 @@
1
+# Jumper Deployment
2
+
3
+Host Manager rulează pe jumper ca serviciu Perl local, ascultând numai pe `127.0.0.1:8088`. Nginx publică aplicația prin vhost pe IP-ul de management `192.168.2.100:80`.
4
+
5
+Vhost implicit:
6
+
7
+```text
8
+hosts.madagascar.xdev.ro
9
+```
10
+
11
+Instanța curentă este instalată pe jumper în `/usr/local/xdev-host-manager` și publicată prin nginx. `/opt` rămâne rezervat pentru aplicații 3rd party/vendor.
12
+
13
+## Pachete
14
+
15
+Se folosesc doar pachete din distribuție:
16
+
17
+- `perl`
18
+- `nginx`
19
+
20
+Nu se instalează npm, pip sau CPAN direct pe host.
21
+
22
+Dacă nginx nu este instalat pe jumper, se instalează din repo-ul distribuției:
23
+
24
+```bash
25
+sudo dnf install nginx
26
+```
27
+
28
+## Layout recomandat
29
+
30
+```text
31
+/usr/local/xdev-host-manager
32
+  config/hosts.yaml
33
+  config/local-hosts.tsv
34
+  scripts/host_manager.pl
35
+  scripts/sync_local_hosts.sh
36
+
37
+/etc/xdev/host-manager.env
38
+/etc/systemd/system/host-manager.service
39
+/etc/nginx/conf.d/hosts.madagascar.xdev.ro.conf
40
+```
41
+
42
+## Instalare manuală
43
+
44
+Pe jumper:
45
+
46
+```bash
47
+id -u host-manager >/dev/null 2>&1 || sudo useradd --system --home-dir /usr/local/xdev-host-manager --shell /usr/sbin/nologin host-manager
48
+sudo install -d -o host-manager -g host-manager /usr/local/xdev-host-manager
49
+sudo install -d -m 0750 /etc/xdev
50
+sudo install -m 0644 deploy/jumper/host-manager.service /etc/systemd/system/host-manager.service
51
+sudo install -m 0644 deploy/jumper/nginx-host-manager.conf /etc/nginx/conf.d/hosts.madagascar.xdev.ro.conf
52
+```
53
+
54
+Copiază `deploy/jumper/host-manager.env.example` la `/etc/xdev/host-manager.env` și setează secretul TOTP real.
55
+
56
+La instalarea inițială se poate genera automat secretul TOTP. URI-ul de bootstrap rămâne doar pe jumper, root-only:
57
+
58
+```bash
59
+sudo cat /etc/xdev/host-manager.totp-uri
60
+```
61
+
62
+Validare:
63
+
64
+```bash
65
+sudo systemctl daemon-reload
66
+sudo systemctl enable --now host-manager
67
+sudo nginx -t
68
+sudo systemctl reload nginx
69
+curl -fsS http://127.0.0.1:8088/healthz
70
+curl -fsS http://hosts.madagascar.xdev.ro/healthz
71
+```
72
+
73
+Verificări de securitate de bază:
74
+
75
+```bash
76
+curl -o /dev/null -w '%{http_code}\n' -X POST http://hosts.madagascar.xdev.ro/api/render/local-hosts-tsv
77
+# trebuie să întoarcă 401 fără sesiune OTP
78
+```
79
+
80
+## DNS local
81
+
82
+Vhost-ul trebuie să existe în registrul intern:
83
+
84
+```text
85
+hosts.madagascar.xdev.ro -> 192.168.2.100
86
+```
87
+
88
+Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat.
+13 -0
deploy/jumper/host-manager.env.example
@@ -0,0 +1,13 @@
1
+# Copy to /etc/xdev/host-manager.env on jumper.
2
+# Do not commit the real file.
3
+
4
+HOST_MANAGER_BIND=127.0.0.1
5
+HOST_MANAGER_PORT=8088
6
+HOST_MANAGER_DATA=/usr/local/xdev-host-manager/config/hosts.yaml
7
+HOST_MANAGER_LOCAL_HOSTS_TSV=/usr/local/xdev-host-manager/config/local-hosts.tsv
8
+
9
+# Base32 TOTP secret. Required for write access.
10
+HOST_MANAGER_TOTP_SECRET=CHANGE_ME_BASE32
11
+
12
+# Optional stable random value used to sign local sessions.
13
+HOST_MANAGER_SESSION_SECRET=CHANGE_ME_RANDOM_HEX
+22 -0
deploy/jumper/host-manager.service
@@ -0,0 +1,22 @@
1
+[Unit]
2
+Description=Xdev Host Manager
3
+After=network-online.target
4
+Wants=network-online.target
5
+
6
+[Service]
7
+Type=simple
8
+User=host-manager
9
+Group=host-manager
10
+WorkingDirectory=/usr/local/xdev-host-manager
11
+EnvironmentFile=/etc/xdev/host-manager.env
12
+ExecStart=/usr/bin/perl /usr/local/xdev-host-manager/scripts/host_manager.pl --bind 127.0.0.1 --port 8088
13
+Restart=on-failure
14
+RestartSec=3
15
+NoNewPrivileges=true
16
+PrivateTmp=true
17
+ProtectSystem=full
18
+ProtectHome=true
19
+ReadWritePaths=/usr/local/xdev-host-manager /usr/local/xdev-host-manager/backups
20
+
21
+[Install]
22
+WantedBy=multi-user.target
+23 -0
deploy/jumper/nginx-host-manager.conf
@@ -0,0 +1,23 @@
1
+server {
2
+    listen 192.168.2.100:80;
3
+    server_name hosts.madagascar.xdev.ro;
4
+
5
+    access_log /var/log/nginx/hosts.madagascar.xdev.ro.access.log main;
6
+    error_log /var/log/nginx/hosts.madagascar.xdev.ro.error.log warn;
7
+
8
+    client_max_body_size 256k;
9
+
10
+    add_header X-Content-Type-Options nosniff always;
11
+    add_header X-Frame-Options DENY always;
12
+    add_header Referrer-Policy no-referrer always;
13
+
14
+    location / {
15
+        proxy_pass http://127.0.0.1:8088;
16
+        proxy_http_version 1.1;
17
+        proxy_set_header Host $host;
18
+        proxy_set_header X-Real-IP $remote_addr;
19
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
20
+        proxy_set_header X-Forwarded-Proto $scheme;
21
+        proxy_read_timeout 30s;
22
+    }
23
+}
+1062 -0
scripts/host_manager.pl
@@ -0,0 +1,1062 @@
1
+#!/usr/bin/env perl
2
+#
3
+# host_manager.pl - Minimal host registry web app with no CPAN dependencies.
4
+#
5
+
6
+use strict;
7
+use warnings;
8
+
9
+use Cwd qw(abs_path);
10
+use Digest::SHA qw(hmac_sha1 hmac_sha256_hex sha256_hex);
11
+use File::Basename qw(dirname);
12
+use File::Path qw(make_path);
13
+use IO::Socket::INET;
14
+use POSIX qw(strftime);
15
+use Time::HiRes qw(time);
16
+
17
+my $script_dir = dirname(abs_path($0));
18
+my $project_dir = dirname($script_dir);
19
+
20
+my %opt = (
21
+    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
22
+    port => $ENV{HOST_MANAGER_PORT} || 8088,
23
+    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
24
+    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
25
+);
26
+
27
+while (@ARGV) {
28
+    my $arg = shift @ARGV;
29
+    if ($arg eq '--bind') {
30
+        $opt{bind} = shift @ARGV;
31
+    } elsif ($arg eq '--port') {
32
+        $opt{port} = shift @ARGV;
33
+    } elsif ($arg eq '--data') {
34
+        $opt{data} = shift @ARGV;
35
+    } elsif ($arg eq '--local-hosts-tsv') {
36
+        $opt{local_hosts_tsv} = shift @ARGV;
37
+    } elsif ($arg eq '--help' || $arg eq '-h') {
38
+        usage();
39
+        exit 0;
40
+    } else {
41
+        die "Unknown option: $arg\n";
42
+    }
43
+}
44
+
45
+my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
46
+my %sessions;
47
+
48
+my $server = IO::Socket::INET->new(
49
+    LocalHost => $opt{bind},
50
+    LocalPort => $opt{port},
51
+    Proto => 'tcp',
52
+    Listen => 10,
53
+    ReuseAddr => 1,
54
+) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
55
+
56
+print "host-manager listening on http://$opt{bind}:$opt{port}\n";
57
+print "data file: $opt{data}\n";
58
+print "OTP login: " . ($ENV{HOST_MANAGER_TOTP_SECRET} ? "enabled\n" : "disabled; set HOST_MANAGER_TOTP_SECRET\n");
59
+
60
+while (my $client = $server->accept) {
61
+    eval {
62
+        $client->autoflush(1);
63
+        handle_client($client);
64
+    };
65
+    if ($@) {
66
+        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
67
+    }
68
+    close $client;
69
+}
70
+
71
+sub usage {
72
+    print <<"EOF";
73
+Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
74
+
75
+Environment:
76
+  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
77
+  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
78
+  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
79
+  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
80
+
81
+Read-only endpoints do not require authentication.
82
+EOF
83
+}
84
+
85
+sub handle_client {
86
+    my ($client) = @_;
87
+    my $request_line = <$client>;
88
+    return unless defined $request_line;
89
+    $request_line =~ s/\r?\n$//;
90
+    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
91
+    return send_text($client, 400, 'bad request') unless $method && $target;
92
+
93
+    my %headers;
94
+    while (my $line = <$client>) {
95
+        $line =~ s/\r?\n$//;
96
+        last if $line eq '';
97
+        my ($k, $v) = split /:\s*/, $line, 2;
98
+        $headers{lc $k} = $v if defined $k && defined $v;
99
+    }
100
+
101
+    my $body = '';
102
+    if (($headers{'content-length'} || 0) > 0) {
103
+        read($client, $body, int($headers{'content-length'}));
104
+    }
105
+
106
+    my ($path, $query) = split /\?/, $target, 2;
107
+    my %query = parse_params($query || '');
108
+
109
+    if ($method eq 'GET' && $path eq '/') {
110
+        return send_html($client, 200, app_html());
111
+    }
112
+    if ($method eq 'GET' && $path eq '/healthz') {
113
+        return send_json($client, 200, { ok => json_bool(1), data => $opt{data} });
114
+    }
115
+    if ($method eq 'GET' && $path eq '/api/session') {
116
+        return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
117
+    }
118
+    if ($method eq 'GET' && $path eq '/api/hosts') {
119
+        my $registry = load_registry();
120
+        return send_json($client, 200, registry_payload($registry));
121
+    }
122
+    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
123
+        return send_file($client, $opt{data}, 'application/x-yaml; charset=utf-8', 'hosts.yaml');
124
+    }
125
+    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
126
+        my $registry = load_registry();
127
+        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
128
+    }
129
+    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
130
+        my $registry = load_registry();
131
+        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
132
+    }
133
+    if ($method eq 'POST' && $path eq '/api/login') {
134
+        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
135
+        my $payload = request_payload(\%headers, $body);
136
+        my $otp = $payload->{otp} || '';
137
+        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
138
+            return send_json($client, 401, { error => 'invalid_otp' });
139
+        }
140
+        my $token = create_session();
141
+        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
142
+    }
143
+    if ($method eq 'POST' && $path eq '/api/logout') {
144
+        expire_session(\%headers);
145
+        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
146
+    }
147
+
148
+    if ($method eq 'POST' && $path =~ m{^/api/}) {
149
+        return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
150
+
151
+        if ($path eq '/api/hosts/upsert') {
152
+            my $payload = request_payload(\%headers, $body);
153
+            return upsert_host($client, $payload);
154
+        }
155
+        if ($path eq '/api/hosts/delete') {
156
+            my $payload = request_payload(\%headers, $body);
157
+            return delete_host($client, $payload->{id} || '');
158
+        }
159
+        if ($path eq '/api/render/local-hosts-tsv') {
160
+            my $registry = load_registry();
161
+            my $content = render_local_hosts_tsv($registry);
162
+            backup_file($opt{local_hosts_tsv});
163
+            write_file($opt{local_hosts_tsv}, $content);
164
+            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
165
+        }
166
+    }
167
+
168
+    return send_json($client, 404, { error => 'not_found' });
169
+}
170
+
171
+sub load_registry {
172
+    return parse_hosts_yaml(read_file($opt{data}));
173
+}
174
+
175
+sub save_registry {
176
+    my ($registry) = @_;
177
+    $registry->{updated_at} = iso_now();
178
+    backup_file($opt{data});
179
+    write_file($opt{data}, render_hosts_yaml($registry));
180
+}
181
+
182
+sub registry_payload {
183
+    my ($registry) = @_;
184
+    my $problems = analyze_hosts($registry->{hosts});
185
+    return {
186
+        version => $registry->{version},
187
+        updated_at => $registry->{updated_at},
188
+        policy => $registry->{policy},
189
+        hosts => $registry->{hosts},
190
+        problems => $problems,
191
+        counts => {
192
+            hosts => scalar @{ $registry->{hosts} },
193
+            problems => scalar @$problems,
194
+        },
195
+    };
196
+}
197
+
198
+sub upsert_host {
199
+    my ($client, $payload) = @_;
200
+    my $id = clean_id($payload->{id} || '');
201
+    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
202
+
203
+    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
204
+    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
205
+    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
206
+
207
+    my @names = clean_list($payload->{names});
208
+    return send_json($client, 400, { error => 'missing_names' }) unless @names;
209
+
210
+    my $registry = load_registry();
211
+    my %host = (
212
+        id => $id,
213
+        status => clean_scalar($payload->{status} || 'active'),
214
+        hosts_ip => $hosts_ip,
215
+        dns_ip => $dns_ip,
216
+        names => \@names,
217
+        roles => [ clean_list($payload->{roles}) ],
218
+        sources => [ clean_list($payload->{sources}) ],
219
+        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
220
+        notes => clean_scalar($payload->{notes} || ''),
221
+    );
222
+
223
+    my $replaced = 0;
224
+    for my $i (0 .. $#{ $registry->{hosts} }) {
225
+        if ($registry->{hosts}->[$i]{id} eq $id) {
226
+            $registry->{hosts}->[$i] = \%host;
227
+            $replaced = 1;
228
+            last;
229
+        }
230
+    }
231
+    push @{ $registry->{hosts} }, \%host unless $replaced;
232
+    save_registry($registry);
233
+    return send_json($client, 200, { ok => json_bool(1), host => \%host });
234
+}
235
+
236
+sub delete_host {
237
+    my ($client, $id) = @_;
238
+    $id = clean_id($id);
239
+    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
240
+
241
+    my $registry = load_registry();
242
+    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
243
+    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
244
+    $registry->{hosts} = \@kept;
245
+    save_registry($registry);
246
+    return send_json($client, 200, { ok => json_bool(1) });
247
+}
248
+
249
+sub analyze_hosts {
250
+    my ($hosts) = @_;
251
+    my @problems;
252
+    my (%names, %ids);
253
+    for my $host (@$hosts) {
254
+        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
255
+        my @fqdn = grep { /\.madagascar\.xdev\.ro$/ } @{ $host->{names} || [] };
256
+        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless @fqdn || ($host->{status} || '') ne 'active';
257
+        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
258
+            if grep { /\.vad\.is\.xdev\.ro$/ } @{ $host->{names} || [] };
259
+        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
260
+            if grep { /^(is|vad|b)-/ } @{ $host->{names} || [] };
261
+        for my $name (@{ $host->{names} || [] }) {
262
+            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
263
+        }
264
+        if (($host->{hosts_ip} || '') ne ($host->{dns_ip} || '') && ($host->{hosts_ip} || '') ne '127.0.0.1') {
265
+            push @problems, problem($host, 'split-ip', 'hosts_ip differs from dns_ip; check that this is intentional');
266
+        }
267
+    }
268
+    return \@problems;
269
+}
270
+
271
+sub problem {
272
+    my ($host, $code, $message) = @_;
273
+    return { host_id => $host->{id}, code => $code, message => $message };
274
+}
275
+
276
+sub render_local_hosts_tsv {
277
+    my ($registry) = @_;
278
+    my $out = "# Local DNS manifest for the madagascar network.\n";
279
+    $out .= "# Generated by scripts/host_manager.pl from config/hosts.yaml.\n";
280
+    $out .= "#\n";
281
+    $out .= "# Format:\n";
282
+    $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
283
+    $out .= "#\n";
284
+    $out .= "# Priority rule:\n";
285
+    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
286
+    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
287
+    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
288
+    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
289
+        next unless ($host->{status} || 'active') eq 'active';
290
+        next unless @{ $host->{names} || [] };
291
+        $out .= join("\t", $host->{hosts_ip}, $host->{dns_ip}, join(' ', @{ $host->{names} })) . "\n";
292
+    }
293
+    return $out;
294
+}
295
+
296
+sub render_monitoring {
297
+    my ($registry) = @_;
298
+    my @hosts;
299
+    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
300
+        next unless ($host->{status} || 'active') eq 'active';
301
+        next if ($host->{monitoring} || 'pending') eq 'disabled';
302
+        push @hosts, {
303
+            id => $host->{id},
304
+            primary_name => $host->{names}[0],
305
+            address => $host->{dns_ip},
306
+            aliases => [ @{ $host->{names} || [] } ],
307
+            roles => [ @{ $host->{roles} || [] } ],
308
+            monitoring => $host->{monitoring} || 'pending',
309
+            notes => $host->{notes} || '',
310
+        };
311
+    }
312
+    return {
313
+        version => $registry->{version},
314
+        generated_at => iso_now(),
315
+        source => 'config/hosts.yaml',
316
+        hosts => \@hosts,
317
+    };
318
+}
319
+
320
+sub parse_hosts_yaml {
321
+    my ($text) = @_;
322
+    my %registry = (
323
+        version => 1,
324
+        updated_at => '',
325
+        policy => {},
326
+        hosts => [],
327
+    );
328
+    my ($section, $current, $list_key);
329
+    for my $line (split /\n/, $text) {
330
+        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
331
+        if ($line =~ /^version:\s*(\d+)/) {
332
+            $registry{version} = int($1);
333
+        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
334
+            $registry{updated_at} = yaml_unquote($1);
335
+        } elsif ($line =~ /^policy:\s*$/) {
336
+            $section = 'policy';
337
+        } elsif ($line =~ /^hosts:\s*$/) {
338
+            $section = 'hosts';
339
+        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
340
+            $registry{policy}{$1} = yaml_unquote($2);
341
+        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
342
+            $current = {
343
+                id => yaml_unquote($1),
344
+                status => 'active',
345
+                hosts_ip => '',
346
+                dns_ip => '',
347
+                names => [],
348
+                roles => [],
349
+                sources => [],
350
+                monitoring => 'pending',
351
+                notes => '',
352
+            };
353
+            push @{ $registry{hosts} }, $current;
354
+            $list_key = undef;
355
+        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
356
+            $list_key = $1;
357
+            $current->{$list_key} ||= [];
358
+        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
359
+            push @{ $current->{$list_key} }, yaml_unquote($1);
360
+        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
361
+            $current->{$1} = yaml_unquote($2);
362
+            $list_key = undef;
363
+        }
364
+    }
365
+    return \%registry;
366
+}
367
+
368
+sub render_hosts_yaml {
369
+    my ($registry) = @_;
370
+    my $out = "version: " . int($registry->{version} || 1) . "\n";
371
+    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
372
+    $out .= "policy:\n";
373
+    for my $key (sort keys %{ $registry->{policy} || {} }) {
374
+        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
375
+    }
376
+    $out .= "hosts:\n";
377
+    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
378
+        $out .= "  - id: " . yq($host->{id}) . "\n";
379
+        for my $key (qw(status hosts_ip dns_ip)) {
380
+            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
381
+        }
382
+        for my $key (qw(names roles sources)) {
383
+            $out .= "    $key:\n";
384
+            for my $value (@{ $host->{$key} || [] }) {
385
+                $out .= "      - " . yq($value) . "\n";
386
+            }
387
+        }
388
+        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
389
+        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
390
+    }
391
+    return $out;
392
+}
393
+
394
+sub request_payload {
395
+    my ($headers, $body) = @_;
396
+    my $type = $headers->{'content-type'} || '';
397
+    if ($type =~ m{application/json}) {
398
+        return json_decode($body || '{}');
399
+    }
400
+    return { parse_params($body || '') };
401
+}
402
+
403
+sub json_bool {
404
+    my ($value) = @_;
405
+    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
406
+}
407
+
408
+sub json_encode {
409
+    my ($value) = @_;
410
+    if (!defined $value) {
411
+        return 'null';
412
+    }
413
+    my $ref = ref($value);
414
+    if (!$ref) {
415
+        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
416
+        return json_string($value);
417
+    }
418
+    if ($ref eq 'HostManager::JSONBool') {
419
+        return $$value ? 'true' : 'false';
420
+    }
421
+    if ($ref eq 'ARRAY') {
422
+        return '[' . join(',', map { json_encode($_) } @$value) . ']';
423
+    }
424
+    if ($ref eq 'HASH') {
425
+        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
426
+    }
427
+    return json_string("$value");
428
+}
429
+
430
+sub json_string {
431
+    my ($value) = @_;
432
+    $value = '' unless defined $value;
433
+    $value =~ s/\\/\\\\/g;
434
+    $value =~ s/"/\\"/g;
435
+    $value =~ s/\n/\\n/g;
436
+    $value =~ s/\r/\\r/g;
437
+    $value =~ s/\t/\\t/g;
438
+    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
439
+    return qq("$value");
440
+}
441
+
442
+sub json_decode {
443
+    my ($text) = @_;
444
+    my $i = 0;
445
+    my $len = length($text);
446
+    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
447
+
448
+    $skip_ws = sub {
449
+        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
450
+    };
451
+
452
+    $parse_string = sub {
453
+        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
454
+        $i++;
455
+        my $out = '';
456
+        while ($i < $len) {
457
+            my $ch = substr($text, $i++, 1);
458
+            return $out if $ch eq '"';
459
+            if ($ch eq "\\") {
460
+                die "Bad JSON escape\n" if $i >= $len;
461
+                my $esc = substr($text, $i++, 1);
462
+                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
463
+                    $out .= $esc;
464
+                } elsif ($esc eq 'b') {
465
+                    $out .= "\b";
466
+                } elsif ($esc eq 'f') {
467
+                    $out .= "\f";
468
+                } elsif ($esc eq 'n') {
469
+                    $out .= "\n";
470
+                } elsif ($esc eq 'r') {
471
+                    $out .= "\r";
472
+                } elsif ($esc eq 't') {
473
+                    $out .= "\t";
474
+                } elsif ($esc eq 'u') {
475
+                    my $hex = substr($text, $i, 4);
476
+                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
477
+                    $out .= chr(hex($hex));
478
+                    $i += 4;
479
+                } else {
480
+                    die "Bad JSON escape\n";
481
+                }
482
+            } else {
483
+                $out .= $ch;
484
+            }
485
+        }
486
+        die "Unterminated JSON string\n";
487
+    };
488
+
489
+    $parse_number = sub {
490
+        my $start = $i;
491
+        $i++ if substr($text, $i, 1) eq '-';
492
+        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
493
+        if ($i < $len && substr($text, $i, 1) eq '.') {
494
+            $i++;
495
+            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
496
+        }
497
+        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
498
+            $i++;
499
+            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
500
+            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
501
+        }
502
+        return 0 + substr($text, $start, $i - $start);
503
+    };
504
+
505
+    $parse_array = sub {
506
+        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
507
+        $i++;
508
+        my @out;
509
+        $skip_ws->();
510
+        if ($i < $len && substr($text, $i, 1) eq ']') {
511
+            $i++;
512
+            return \@out;
513
+        }
514
+        while (1) {
515
+            push @out, $parse_value->();
516
+            $skip_ws->();
517
+            my $ch = substr($text, $i++, 1);
518
+            last if $ch eq ']';
519
+            die "Expected JSON array comma\n" unless $ch eq ',';
520
+        }
521
+        return \@out;
522
+    };
523
+
524
+    $parse_object = sub {
525
+        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
526
+        $i++;
527
+        my %out;
528
+        $skip_ws->();
529
+        if ($i < $len && substr($text, $i, 1) eq '}') {
530
+            $i++;
531
+            return \%out;
532
+        }
533
+        while (1) {
534
+            $skip_ws->();
535
+            my $key = $parse_string->();
536
+            $skip_ws->();
537
+            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
538
+            $out{$key} = $parse_value->();
539
+            $skip_ws->();
540
+            my $ch = substr($text, $i++, 1);
541
+            last if $ch eq '}';
542
+            die "Expected JSON object comma\n" unless $ch eq ',';
543
+        }
544
+        return \%out;
545
+    };
546
+
547
+    $parse_value = sub {
548
+        $skip_ws->();
549
+        die "Unexpected end of JSON\n" if $i >= $len;
550
+        my $ch = substr($text, $i, 1);
551
+        return $parse_string->() if $ch eq '"';
552
+        return $parse_object->() if $ch eq '{';
553
+        return $parse_array->() if $ch eq '[';
554
+        if (substr($text, $i, 4) eq 'true') {
555
+            $i += 4;
556
+            return json_bool(1);
557
+        }
558
+        if (substr($text, $i, 5) eq 'false') {
559
+            $i += 5;
560
+            return json_bool(0);
561
+        }
562
+        if (substr($text, $i, 4) eq 'null') {
563
+            $i += 4;
564
+            return undef;
565
+        }
566
+        return $parse_number->() if $ch =~ /[-0-9]/;
567
+        die "Unexpected JSON token\n";
568
+    };
569
+
570
+    my $value = $parse_value->();
571
+    $skip_ws->();
572
+    die "Trailing JSON content\n" if $i != $len;
573
+    return $value;
574
+}
575
+
576
+sub parse_params {
577
+    my ($text) = @_;
578
+    my %out;
579
+    for my $pair (split /&/, $text) {
580
+        next unless length $pair;
581
+        my ($k, $v) = split /=/, $pair, 2;
582
+        $out{url_decode($k)} = url_decode($v || '');
583
+    }
584
+    return %out;
585
+}
586
+
587
+sub clean_id {
588
+    my ($value) = @_;
589
+    $value = lc clean_scalar($value);
590
+    $value =~ s/[^a-z0-9_.-]+/-/g;
591
+    $value =~ s/^-+|-+$//g;
592
+    return $value;
593
+}
594
+
595
+sub clean_scalar {
596
+    my ($value) = @_;
597
+    $value = '' unless defined $value;
598
+    $value =~ s/[\r\n\t]+/ /g;
599
+    $value =~ s/^\s+|\s+$//g;
600
+    return $value;
601
+}
602
+
603
+sub clean_list {
604
+    my ($value) = @_;
605
+    return () unless defined $value;
606
+    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
607
+    my @clean;
608
+    for my $item (@items) {
609
+        $item = clean_scalar($item);
610
+        push @clean, $item if length $item;
611
+    }
612
+    return @clean;
613
+}
614
+
615
+sub yq {
616
+    my ($value) = @_;
617
+    $value = '' unless defined $value;
618
+    $value =~ s/\\/\\\\/g;
619
+    $value =~ s/"/\\"/g;
620
+    return qq("$value");
621
+}
622
+
623
+sub yaml_unquote {
624
+    my ($value) = @_;
625
+    $value = '' unless defined $value;
626
+    $value =~ s/^\s+|\s+$//g;
627
+    if ($value =~ /^"(.*)"$/) {
628
+        $value = $1;
629
+        $value =~ s/\\"/"/g;
630
+        $value =~ s/\\\\/\\/g;
631
+    }
632
+    return $value;
633
+}
634
+
635
+sub verify_totp {
636
+    my ($secret, $otp) = @_;
637
+    return 0 unless $secret && $otp =~ /^\d{6}$/;
638
+    my $key = eval { base32_decode($secret) };
639
+    return 0 if $@ || !length $key;
640
+    my $counter = int(time() / 30);
641
+    for my $offset (-1, 0, 1) {
642
+        return 1 if totp_code($key, $counter + $offset) eq $otp;
643
+    }
644
+    return 0;
645
+}
646
+
647
+sub totp_code {
648
+    my ($key, $counter) = @_;
649
+    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
650
+    my $hash = hmac_sha1($msg, $key);
651
+    my $offset = ord(substr($hash, -1)) & 0x0f;
652
+    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
653
+    return sprintf('%06d', $bin % 1_000_000);
654
+}
655
+
656
+sub base32_decode {
657
+    my ($text) = @_;
658
+    $text = uc($text || '');
659
+    $text =~ s/[^A-Z2-7]//g;
660
+    my %map;
661
+    my @chars = ('A'..'Z', '2'..'7');
662
+    @map{@chars} = (0..31);
663
+    my ($bits, $value, $out) = (0, 0, '');
664
+    for my $char (split //, $text) {
665
+        die "Invalid base32\n" unless exists $map{$char};
666
+        $value = ($value << 5) | $map{$char};
667
+        $bits += 5;
668
+        while ($bits >= 8) {
669
+            $bits -= 8;
670
+            $out .= chr(($value >> $bits) & 0xff);
671
+        }
672
+    }
673
+    return $out;
674
+}
675
+
676
+sub create_session {
677
+    my $nonce = random_hex(24);
678
+    my $expires = int(time() + 8 * 3600);
679
+    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
680
+    my $token = "$nonce:$expires:$sig";
681
+    $sessions{$token} = $expires;
682
+    return $token;
683
+}
684
+
685
+sub is_authenticated {
686
+    my ($headers) = @_;
687
+    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
688
+    return 0 unless $token;
689
+    my ($nonce, $expires, $sig) = split /:/, $token;
690
+    return 0 unless $nonce && $expires && $sig;
691
+    return 0 if $expires < time();
692
+    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
693
+    return exists $sessions{$token};
694
+}
695
+
696
+sub expire_session {
697
+    my ($headers) = @_;
698
+    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
699
+    delete $sessions{$token} if $token;
700
+}
701
+
702
+sub cookie_value {
703
+    my ($cookie, $name) = @_;
704
+    for my $part (split /;\s*/, $cookie) {
705
+        my ($k, $v) = split /=/, $part, 2;
706
+        return $v if defined $k && $k eq $name;
707
+    }
708
+    return '';
709
+}
710
+
711
+sub send_json {
712
+    my ($client, $status, $payload, $extra_headers) = @_;
713
+    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
714
+}
715
+
716
+sub send_html {
717
+    my ($client, $status, $html) = @_;
718
+    return send_response($client, $status, $html, 'text/html; charset=utf-8');
719
+}
720
+
721
+sub send_text {
722
+    my ($client, $status, $text) = @_;
723
+    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
724
+}
725
+
726
+sub send_download {
727
+    my ($client, $status, $content, $type, $filename) = @_;
728
+    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
729
+}
730
+
731
+sub send_file {
732
+    my ($client, $path, $type, $filename) = @_;
733
+    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
734
+    return send_download($client, 200, read_file($path), $type, $filename);
735
+}
736
+
737
+sub send_response {
738
+    my ($client, $status, $body, $type, $extra_headers) = @_;
739
+    my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 500 => 'Internal Server Error', 503 => 'Service Unavailable');
740
+    $body = '' unless defined $body;
741
+    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
742
+    print $client "Content-Type: $type\r\n";
743
+    print $client "Content-Length: " . length($body) . "\r\n";
744
+    print $client "Cache-Control: no-store\r\n";
745
+    print $client "$_\r\n" for @{ $extra_headers || [] };
746
+    print $client "Connection: close\r\n\r\n";
747
+    print $client $body;
748
+}
749
+
750
+sub read_file {
751
+    my ($path) = @_;
752
+    open my $fh, '<', $path or die "Cannot read $path: $!";
753
+    local $/;
754
+    return <$fh>;
755
+}
756
+
757
+sub write_file {
758
+    my ($path, $content) = @_;
759
+    open my $fh, '>', $path or die "Cannot write $path: $!";
760
+    print {$fh} $content;
761
+    close $fh or die "Cannot close $path: $!";
762
+}
763
+
764
+sub backup_file {
765
+    my ($path) = @_;
766
+    return unless -f $path;
767
+    my $backup_dir = "$project_dir/backups/host-manager";
768
+    make_path($backup_dir) unless -d $backup_dir;
769
+    my $name = $path;
770
+    $name =~ s{.*/}{};
771
+    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
772
+    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
773
+}
774
+
775
+sub url_decode {
776
+    my ($value) = @_;
777
+    $value = '' unless defined $value;
778
+    $value =~ tr/+/ /;
779
+    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
780
+    return $value;
781
+}
782
+
783
+sub random_hex {
784
+    my ($bytes) = @_;
785
+    if (open my $fh, '<:raw', '/dev/urandom') {
786
+        read($fh, my $raw, $bytes);
787
+        close $fh;
788
+        return unpack('H*', $raw);
789
+    }
790
+    return sha256_hex(rand() . time() . $$);
791
+}
792
+
793
+sub iso_now {
794
+    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
795
+}
796
+
797
+sub app_html {
798
+    return <<'HTML';
799
+<!doctype html>
800
+<html lang="ro">
801
+<head>
802
+  <meta charset="utf-8">
803
+  <meta name="viewport" content="width=device-width, initial-scale=1">
804
+  <title>Host Manager</title>
805
+  <style>
806
+    :root {
807
+      color-scheme: light;
808
+      --ink: #152033;
809
+      --muted: #647084;
810
+      --line: #d8dee8;
811
+      --soft: #f4f6f9;
812
+      --panel: #ffffff;
813
+      --accent: #1267d8;
814
+      --bad: #b42318;
815
+      --warn: #946200;
816
+      --ok: #137333;
817
+    }
818
+    * { box-sizing: border-box; }
819
+    body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #eef2f6; font-size: 14px; }
820
+    header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
821
+    h1 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: 0; }
822
+    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
823
+    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
824
+    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
825
+    .panel { overflow: hidden; }
826
+    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
827
+    .panel-head h2 { margin: 0; font-size: 14px; }
828
+    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
829
+    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
830
+    button, input, select, textarea { font: inherit; }
831
+    button, .linkbtn { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 6px; padding: 7px 10px; min-height: 34px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
832
+    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
833
+    button.danger { color: var(--bad); }
834
+    button:disabled { opacity: .55; cursor: not-allowed; }
835
+    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
836
+    textarea { min-height: 74px; resize: vertical; }
837
+    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
838
+    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
839
+    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
840
+    tr:hover td { background: #f8fafc; }
841
+    .pill { display: inline-block; padding: 2px 6px; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); color: var(--muted); font-size: 12px; margin: 0 4px 4px 0; }
842
+    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
843
+    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
844
+    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
845
+    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
846
+    .span2 { grid-column: 1 / -1; }
847
+    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
848
+    .auth { display: flex; gap: 8px; align-items: center; }
849
+    .auth input { width: 130px; }
850
+    .muted { color: var(--muted); }
851
+    .problems { padding: 10px 14px; display: grid; gap: 8px; }
852
+    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
853
+    @media (max-width: 760px) {
854
+      header { align-items: stretch; flex-direction: column; }
855
+      .grid { grid-template-columns: 1fr; }
856
+      table { min-width: 760px; }
857
+      .table-wrap { overflow-x: auto; }
858
+    }
859
+  </style>
860
+</head>
861
+<body>
862
+  <header>
863
+    <h1>Host Manager</h1>
864
+    <form class="auth" id="login-form">
865
+      <span id="auth-state" class="muted">read-only</span>
866
+      <input id="otp" name="otp" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="OTP">
867
+      <button class="primary" type="submit">Login</button>
868
+      <button type="button" id="logout">Logout</button>
869
+    </form>
870
+  </header>
871
+  <main>
872
+    <section class="toolbar">
873
+      <button id="refresh">Refresh</button>
874
+      <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
875
+      <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
876
+      <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
877
+      <button id="write-tsv">Write local-hosts.tsv</button>
878
+      <span id="message" class="muted"></span>
879
+    </section>
880
+
881
+    <section class="panel">
882
+      <div class="panel-head">
883
+        <h2>Overview</h2>
884
+        <div class="stats" id="stats"></div>
885
+      </div>
886
+      <div class="problems" id="problems"></div>
887
+    </section>
888
+
889
+    <section class="panel">
890
+      <div class="panel-head">
891
+        <h2>Hosts</h2>
892
+        <input id="filter" placeholder="filter" style="max-width: 240px">
893
+      </div>
894
+      <div class="table-wrap">
895
+        <table>
896
+          <thead>
897
+            <tr>
898
+              <th style="width: 120px">ID</th>
899
+              <th style="width: 130px">hosts_ip</th>
900
+              <th style="width: 130px">dns_ip</th>
901
+              <th>Names</th>
902
+              <th style="width: 150px">Roles</th>
903
+              <th style="width: 110px">Monitoring</th>
904
+              <th style="width: 90px">Status</th>
905
+            </tr>
906
+          </thead>
907
+          <tbody id="hosts"></tbody>
908
+        </table>
909
+      </div>
910
+    </section>
911
+
912
+    <section class="panel">
913
+      <div class="panel-head">
914
+        <h2>Edit host</h2>
915
+        <span class="muted">write access requires OTP</span>
916
+      </div>
917
+      <form id="host-form" class="grid">
918
+        <label>ID<input name="id" required></label>
919
+        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
920
+        <label>hosts_ip<input name="hosts_ip" required></label>
921
+        <label>dns_ip<input name="dns_ip" required></label>
922
+        <label class="span2">Names<textarea name="names" required></textarea></label>
923
+        <label>Roles<input name="roles"></label>
924
+        <label>Sources<input name="sources"></label>
925
+        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
926
+        <label>Notes<input name="notes"></label>
927
+        <div class="span2">
928
+          <button class="primary" type="submit">Save host</button>
929
+          <button class="danger" type="button" id="delete-host">Delete host</button>
930
+        </div>
931
+      </form>
932
+    </section>
933
+  </main>
934
+  <script>
935
+    let state = { hosts: [], problems: [], authenticated: false };
936
+
937
+    const $ = (id) => document.getElementById(id);
938
+    const msg = (text) => { $('message').textContent = text || ''; };
939
+
940
+    async function api(path, options = {}) {
941
+      const res = await fetch(path, options);
942
+      const body = await res.json();
943
+      if (!res.ok) throw new Error(body.error || res.statusText);
944
+      return body;
945
+    }
946
+
947
+    async function refresh() {
948
+      const session = await api('/api/session');
949
+      state.authenticated = session.authenticated;
950
+      $('auth-state').textContent = state.authenticated ? 'authenticated' : 'read-only';
951
+      const data = await api('/api/hosts');
952
+      state.hosts = data.hosts || [];
953
+      state.problems = data.problems || [];
954
+      render(data);
955
+    }
956
+
957
+    function render(data) {
958
+      $('stats').innerHTML = [
959
+        ['hosts', data.counts.hosts],
960
+        ['problems', data.counts.problems],
961
+        ['updated', data.updated_at || 'unknown']
962
+      ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
963
+
964
+      $('problems').innerHTML = state.problems.length
965
+        ? state.problems.map(p => `<div class="problem"><strong>${escapeHtml(p.host_id)}</strong> ${escapeHtml(p.code)}: ${escapeHtml(p.message)}</div>`).join('')
966
+        : '<div class="muted" style="padding: 8px 0">No registry problems detected.</div>';
967
+
968
+      renderHosts();
969
+    }
970
+
971
+    function renderHosts() {
972
+      const filter = $('filter').value.toLowerCase();
973
+      $('hosts').innerHTML = state.hosts
974
+        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
975
+        .map(h => {
976
+          const problems = state.problems.filter(p => p.host_id === h.id);
977
+          const cls = problems.length ? 'warn' : 'ok';
978
+          return `<tr data-id="${escapeHtml(h.id)}">
979
+            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
980
+            <td>${escapeHtml(h.hosts_ip || '')}</td>
981
+            <td>${escapeHtml(h.dns_ip || '')}</td>
982
+            <td>${(h.names || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
983
+            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
984
+            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
985
+            <td>${escapeHtml(h.status || '')}</td>
986
+          </tr>`;
987
+        }).join('');
988
+      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
989
+    }
990
+
991
+    function editHost(id) {
992
+      const host = state.hosts.find(h => h.id === id);
993
+      if (!host) return;
994
+      const form = $('host-form');
995
+      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) form.elements[key].value = host[key] || '';
996
+      form.elements.names.value = (host.names || []).join('\n');
997
+      form.elements.roles.value = (host.roles || []).join(' ');
998
+      form.elements.sources.value = (host.sources || []).join(' ');
999
+      window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
1000
+    }
1001
+
1002
+    function formObject(form) {
1003
+      return Object.fromEntries(new FormData(form).entries());
1004
+    }
1005
+
1006
+    function escapeHtml(value) {
1007
+      return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
1008
+    }
1009
+
1010
+    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
1011
+    $('filter').addEventListener('input', renderHosts);
1012
+
1013
+    $('login-form').addEventListener('submit', async (event) => {
1014
+      event.preventDefault();
1015
+      try {
1016
+        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: $('otp').value }) });
1017
+        $('otp').value = '';
1018
+        msg('authenticated');
1019
+        await refresh();
1020
+      } catch (e) { msg(e.message); }
1021
+    });
1022
+
1023
+    $('logout').addEventListener('click', async () => {
1024
+      await api('/api/logout', { method: 'POST' }).catch(() => {});
1025
+      msg('logged out');
1026
+      await refresh();
1027
+    });
1028
+
1029
+    $('host-form').addEventListener('submit', async (event) => {
1030
+      event.preventDefault();
1031
+      try {
1032
+        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
1033
+        msg('host saved');
1034
+        await refresh();
1035
+      } catch (e) { msg(e.message); }
1036
+    });
1037
+
1038
+    $('delete-host').addEventListener('click', async () => {
1039
+      const id = $('host-form').elements.id.value;
1040
+      if (!id || !confirm(`Delete ${id}?`)) return;
1041
+      try {
1042
+        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
1043
+        $('host-form').reset();
1044
+        msg('host deleted');
1045
+        await refresh();
1046
+      } catch (e) { msg(e.message); }
1047
+    });
1048
+
1049
+    $('write-tsv').addEventListener('click', async () => {
1050
+      if (!confirm('Write config/local-hosts.tsv from hosts.yaml?')) return;
1051
+      try {
1052
+        await api('/api/render/local-hosts-tsv', { method: 'POST' });
1053
+        msg('local-hosts.tsv written');
1054
+      } catch (e) { msg(e.message); }
1055
+    });
1056
+
1057
+    refresh().catch(e => msg(e.message));
1058
+  </script>
1059
+</body>
1060
+</html>
1061
+HTML
1062
+}
+282 -0
scripts/sync_local_hosts.sh
@@ -0,0 +1,282 @@
1
+#!/usr/bin/env bash
2
+#
3
+# sync_local_hosts.sh - Sync local madagascar DNS records to is-vpn-gw and as01.
4
+#
5
+
6
+set -euo pipefail
7
+
8
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
10
+CONFIG_FILE="$PROJECT_DIR/config/local-hosts.tsv"
11
+IS_VPN_GW="is-vpn-gw"
12
+AS01="admin@192.168.2.2"
13
+APPLY=false
14
+VERIFY=false
15
+TARGET="all"
16
+NEGATIVE_NAME="nohost.madagascar.xdev.ro"
17
+
18
+usage() {
19
+    cat << EOF
20
+Usage: $0 [--apply] [--verify] [--target all|is-vpn-gw|as01] [-c file]
21
+
22
+Default mode is dry-run. Use --apply to change remote resolvers.
23
+
24
+Examples:
25
+  $0
26
+  $0 --apply --verify
27
+  $0 --target as01 --apply
28
+EOF
29
+}
30
+
31
+log() {
32
+    printf '[INFO] %s\n' "$*"
33
+}
34
+
35
+die() {
36
+    printf '[ERROR] %s\n' "$*" >&2
37
+    exit 1
38
+}
39
+
40
+while [[ $# -gt 0 ]]; do
41
+    case "$1" in
42
+        --apply) APPLY=true; shift ;;
43
+        --verify) VERIFY=true; shift ;;
44
+        --target) TARGET="${2:-}"; shift 2 ;;
45
+        -c|--config) CONFIG_FILE="${2:-}"; shift 2 ;;
46
+        -h|--help) usage; exit 0 ;;
47
+        *) die "Unknown option: $1" ;;
48
+    esac
49
+done
50
+
51
+case "$TARGET" in
52
+    all|is-vpn-gw|as01) ;;
53
+    *) die "Invalid target: $TARGET" ;;
54
+esac
55
+
56
+[[ -f "$CONFIG_FILE" ]] || die "Missing config file: $CONFIG_FILE"
57
+
58
+WORK_DIR="$(mktemp -d)"
59
+trap 'rm -rf "$WORK_DIR"' EXIT
60
+
61
+HOSTS_ROWS="$WORK_DIR/hosts.rows"
62
+CLOAK_ROWS="$WORK_DIR/cloak.rows"
63
+NAMES_FILE="$WORK_DIR/names.txt"
64
+MIKROTIK_RSC="$WORK_DIR/as01.rsc"
65
+VERIFY_ROWS="$WORK_DIR/verify.rows"
66
+
67
+touch "$HOSTS_ROWS" "$CLOAK_ROWS" "$NAMES_FILE" "$MIKROTIK_RSC" "$VERIFY_ROWS"
68
+
69
+quote_ros() {
70
+    printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
71
+}
72
+
73
+while IFS= read -r line || [[ -n "$line" ]]; do
74
+    [[ -z "${line//[[:space:]]/}" ]] && continue
75
+    [[ "$line" =~ ^[[:space:]]*# ]] && continue
76
+
77
+    read -r hosts_ip dns_ip names <<< "$line"
78
+    [[ -n "${hosts_ip:-}" && -n "${dns_ip:-}" && -n "${names:-}" ]] || die "Invalid row: $line"
79
+
80
+    printf '%s   %s\n' "$hosts_ip" "$names" >> "$HOSTS_ROWS"
81
+
82
+    for name in $names; do
83
+        printf '%s\n' "$name" >> "$NAMES_FILE"
84
+        printf '%-40s %s\n' "$name" "$dns_ip" >> "$CLOAK_ROWS"
85
+        if [[ "$name" == *.xdev.ro ]]; then
86
+            printf '%s %s\n' "$name" "$dns_ip" >> "$VERIFY_ROWS"
87
+        fi
88
+
89
+        ros_name="$(quote_ros "$name")"
90
+        ros_ip="$(quote_ros "$dns_ip")"
91
+        {
92
+            printf '/ip dns static remove [find name="%s"]\n' "$ros_name"
93
+            printf '/ip dns static add name="%s" type=A address=%s comment="xdev-local managed"\n' "$ros_name" "$ros_ip"
94
+        } >> "$MIKROTIK_RSC"
95
+    done
96
+done < "$CONFIG_FILE"
97
+
98
+sort -u "$NAMES_FILE" -o "$NAMES_FILE"
99
+printf '/ip dns cache flush\n' >> "$MIKROTIK_RSC"
100
+printf ':put "xdev local dns sync complete"\n' >> "$MIKROTIK_RSC"
101
+
102
+sync_is_vpn_gw() {
103
+    if ! $APPLY; then
104
+        log "Dry-run for $IS_VPN_GW: generated /etc/hosts block"
105
+        sed -n '1,80p' "$HOSTS_ROWS"
106
+        log "Dry-run for $IS_VPN_GW: generated cloaking-rules block"
107
+        sed -n '1,120p' "$CLOAK_ROWS"
108
+        return
109
+    fi
110
+
111
+    log "Syncing $IS_VPN_GW"
112
+    remote_dir="/tmp/xdev-local-dns.$$"
113
+    ssh "$IS_VPN_GW" "mkdir -p '$remote_dir'"
114
+    scp "$HOSTS_ROWS" "$CLOAK_ROWS" "$NAMES_FILE" "$IS_VPN_GW:$remote_dir/" >/dev/null
115
+    ssh "$IS_VPN_GW" "REMOTE_DIR='$remote_dir' bash -s" <<'REMOTE'
116
+set -euo pipefail
117
+stamp="$(date +%Y%m%d_%H%M%S)"
118
+cp /etc/hosts "/etc/hosts.bak.$stamp"
119
+cp /etc/dnscrypt-proxy/cloaking-rules.txt "/etc/dnscrypt-proxy/cloaking-rules.txt.bak.$stamp"
120
+
121
+awk '
122
+    NR == FNR { names[$1] = 1; next }
123
+    /^# BEGIN XDEV LOCAL DNS MANAGED BLOCK/ { in_managed = 1; next }
124
+    /^# END XDEV LOCAL DNS MANAGED BLOCK/ { in_managed = 0; next }
125
+    in_managed { next }
126
+    /^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next }
127
+    {
128
+        keep = 1
129
+        for (i = 2; i <= NF; i++) {
130
+            if ($i in names) keep = 0
131
+        }
132
+        if (keep) print
133
+    }
134
+' "$REMOTE_DIR/names.txt" /etc/hosts > "$REMOTE_DIR/hosts.new"
135
+{
136
+    cat "$REMOTE_DIR/hosts.new"
137
+    printf "\n# BEGIN XDEV LOCAL DNS MANAGED BLOCK\n"
138
+    cat "$REMOTE_DIR/hosts.rows"
139
+    printf "# END XDEV LOCAL DNS MANAGED BLOCK\n"
140
+} > /etc/hosts
141
+
142
+awk '
143
+    NR == FNR { names[$1] = 1; next }
144
+    /^# BEGIN XDEV LOCAL DNS MANAGED BLOCK/ { in_managed = 1; next }
145
+    /^# END XDEV LOCAL DNS MANAGED BLOCK/ { in_managed = 0; next }
146
+    in_managed { next }
147
+    /^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next }
148
+    {
149
+        if (!($1 in names)) print
150
+    }
151
+' "$REMOTE_DIR/names.txt" /etc/dnscrypt-proxy/cloaking-rules.txt > "$REMOTE_DIR/cloak.new"
152
+{
153
+    cat "$REMOTE_DIR/cloak.new"
154
+    printf "\n# BEGIN XDEV LOCAL DNS MANAGED BLOCK\n"
155
+    cat "$REMOTE_DIR/cloak.rows"
156
+    printf "# END XDEV LOCAL DNS MANAGED BLOCK\n"
157
+} > /etc/dnscrypt-proxy/cloaking-rules.txt
158
+
159
+resolved_backup=""
160
+if ! grep -Eq '^ReadEtcHosts=no$' /etc/systemd/resolved.conf; then
161
+    cp /etc/systemd/resolved.conf "/etc/systemd/resolved.conf.bak.$stamp"
162
+    resolved_backup=" /etc/systemd/resolved.conf.bak.$stamp"
163
+    if grep -Eq '^ReadEtcHosts=' /etc/systemd/resolved.conf; then
164
+        sed -i 's/^ReadEtcHosts=.*/ReadEtcHosts=no/' /etc/systemd/resolved.conf
165
+    else
166
+        printf "\nReadEtcHosts=no\n" >> /etc/systemd/resolved.conf
167
+    fi
168
+fi
169
+
170
+if grep -Eq '^DNSStubListenerExtra=' /etc/systemd/resolved.conf; then
171
+    if [[ -z "$resolved_backup" ]]; then
172
+        cp /etc/systemd/resolved.conf "/etc/systemd/resolved.conf.bak.$stamp"
173
+        resolved_backup=" /etc/systemd/resolved.conf.bak.$stamp"
174
+    fi
175
+    sed -i 's/^DNSStubListenerExtra=/#DNSStubListenerExtra=/' /etc/systemd/resolved.conf
176
+fi
177
+
178
+dnscrypt_resolved_conf="/etc/systemd/resolved.conf.d/20-dnscrypt.conf"
179
+mkdir -p /etc/systemd/resolved.conf.d
180
+if [[ ! -f "$dnscrypt_resolved_conf" ]] || ! grep -Eq '^DNS=127\.0\.0\.1:5300$' "$dnscrypt_resolved_conf"; then
181
+    if [[ -f "$dnscrypt_resolved_conf" ]]; then
182
+        cp "$dnscrypt_resolved_conf" "$dnscrypt_resolved_conf.bak.$stamp"
183
+        resolved_backup="$resolved_backup $dnscrypt_resolved_conf.bak.$stamp"
184
+    fi
185
+    cat > "$dnscrypt_resolved_conf" <<'EOF'
186
+[Resolve]
187
+# Send LAN DNS traffic through dnscrypt-proxy so cloaking-rules are authoritative locally.
188
+DNS=127.0.0.1:5300
189
+FallbackDNS=
190
+EOF
191
+fi
192
+
193
+dnscrypt_conf="/etc/dnscrypt-proxy/dnscrypt-proxy.toml"
194
+expected_listen="listen_addresses = ['127.0.0.1:5300', '192.168.2.100:53']"
195
+dnscrypt_backup=""
196
+if ! grep -Fqx "$expected_listen" "$dnscrypt_conf"; then
197
+    cp "$dnscrypt_conf" "$dnscrypt_conf.bak.$stamp"
198
+    dnscrypt_backup=" $dnscrypt_conf.bak.$stamp"
199
+    sed -i "s|^listen_addresses = .*|$expected_listen|" "$dnscrypt_conf"
200
+fi
201
+
202
+allowed_names_file="/etc/dnscrypt-proxy/allowed-names.txt"
203
+if ! grep -Eq "^allowed_names_file = '/etc/dnscrypt-proxy/allowed-names.txt'$" "$dnscrypt_conf"; then
204
+    if [[ -z "$dnscrypt_backup" ]]; then
205
+        cp "$dnscrypt_conf" "$dnscrypt_conf.bak.$stamp"
206
+        dnscrypt_backup=" $dnscrypt_conf.bak.$stamp"
207
+    fi
208
+    if grep -Eq '^[#[:space:]]*allowed_names_file =' "$dnscrypt_conf"; then
209
+        sed -i "s|^[#[:space:]]*allowed_names_file = .*|allowed_names_file = '/etc/dnscrypt-proxy/allowed-names.txt'|" "$dnscrypt_conf"
210
+    else
211
+        sed -i "/^\\[allowed_names\\]/a allowed_names_file = '/etc/dnscrypt-proxy/allowed-names.txt'" "$dnscrypt_conf"
212
+    fi
213
+fi
214
+
215
+for allowed_name in google.com www.google.com; do
216
+    grep -Fxq "$allowed_name" "$allowed_names_file" || printf "%s\n" "$allowed_name" >> "$allowed_names_file"
217
+done
218
+
219
+resolvectl flush-caches || true
220
+systemctl restart systemd-resolved
221
+
222
+systemctl restart dnscrypt-proxy
223
+rm -rf "$REMOTE_DIR"
224
+printf "backups: /etc/hosts.bak.%s /etc/dnscrypt-proxy/cloaking-rules.txt.bak.%s%s%s\n" "$stamp" "$stamp" "$resolved_backup" "$dnscrypt_backup"
225
+REMOTE
226
+}
227
+
228
+sync_as01() {
229
+    if ! $APPLY; then
230
+        log "Dry-run for as01: generated RouterOS commands"
231
+        sed -n '1,160p' "$MIKROTIK_RSC"
232
+        return
233
+    fi
234
+
235
+    log "Syncing as01"
236
+    ssh "$AS01" "/ip dns static remove [find comment=\"xdev-local managed\"]"$'\n'"$(cat "$MIKROTIK_RSC")"
237
+}
238
+
239
+verify_resolver() {
240
+    local resolver="$1"
241
+    local failures=0
242
+
243
+    log "Verifying resolver $resolver"
244
+    while read -r name expected_ip; do
245
+        answers="$(dig @"$resolver" "$name" +short || true)"
246
+        if ! grep -Fxq "$expected_ip" <<< "$answers"; then
247
+            compact_answers="$(paste -s -d ',' - <<< "$answers")"
248
+            printf '[FAIL] %s %s expected %s got %s\n' "$resolver" "$name" "$expected_ip" "${compact_answers:-<empty>}" >&2
249
+            failures=$((failures + 1))
250
+        fi
251
+    done < "$VERIFY_ROWS"
252
+
253
+    dns_status="$(dig @"$resolver" "$NEGATIVE_NAME" +noall +comments | awk '/status:/ {print $6}' | tr -d ',' || true)"
254
+    if [[ "$dns_status" != "NXDOMAIN" ]]; then
255
+        printf '[FAIL] %s %s expected NXDOMAIN got %s\n' "$resolver" "$NEGATIVE_NAME" "${dns_status:-<empty>}" >&2
256
+        failures=$((failures + 1))
257
+    fi
258
+
259
+    [[ "$failures" -eq 0 ]]
260
+}
261
+
262
+if $APPLY || ! $VERIFY; then
263
+    case "$TARGET" in
264
+        all)
265
+            sync_is_vpn_gw
266
+            sync_as01
267
+            ;;
268
+        is-vpn-gw) sync_is_vpn_gw ;;
269
+        as01) sync_as01 ;;
270
+    esac
271
+fi
272
+
273
+if $VERIFY; then
274
+    case "$TARGET" in
275
+        all)
276
+            verify_resolver 192.168.2.100
277
+            verify_resolver 192.168.2.2
278
+            ;;
279
+        is-vpn-gw) verify_resolver 192.168.2.100 ;;
280
+        as01) verify_resolver 192.168.2.2 ;;
281
+    esac
282
+fi