@@ -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. |
|
@@ -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`. |
|
@@ -0,0 +1,7 @@ |
||
| 1 |
+/backups/ |
|
| 2 |
+*.log |
|
| 3 |
+*.tmp |
|
| 4 |
+*.temp |
|
| 5 |
+*~ |
|
| 6 |
+.DS_Store |
|
| 7 |
+config/host-manager.env |
|
@@ -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`. |
|
@@ -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." |
|
@@ -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 |
|
@@ -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. |
|
@@ -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 |
|
@@ -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 |
|
@@ -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 |
+} |
|
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 |
+} |
|
@@ -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 |
|