diff --git a/.gitignore b/.gitignore index 0118f27..a635ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ input/ .vscode/ *.swp *.swo + +# Secrets - real cPanel DNS hook config holds an API token (template is committed) +cpanel-dns.config.json +**/cpanel-dns.config.json diff --git a/proxmox/vm201-windows/README.md b/proxmox/vm201-windows/README.md index 9870db2..38787dd 100644 --- a/proxmox/vm201-windows/README.md +++ b/proxmox/vm201-windows/README.md @@ -166,7 +166,12 @@ format manifest, workflow publicare, riscuri backup). - **Locație:** `C:\Tools\win-acme\` - **Scop:** Certificate Let's Encrypt automate - **Task Scheduler:** Reînnoire automată zilnică (verificare) -- **Certificate Storage:** LocalMachine\My (Certificate Store) +- **Certificate Storage:** LocalMachine\My (Certificate Store); certul wildcard în store-ul `WebHosting` +- **Renewal-uri:** + - `roa.romfast.ro`, `dokploy`, `gitea`, `roa2web`, `roa-qr` → validare **HTTP-01** (sursă IIS, auto) + - `roa-wildcard-cpanel` (`*.roa.romfast.ro`) → validare **DNS-01** prin hook cPanel + (`cpanel-acme-dns.ps1`), instalat pe site `roa-apps` (ID 6). Vezi incidentul de + expirare 2026-05-31 și setup-ul complet în `vm201-certificat-letsencrypt-iis.md`. #### 4. WinNUT Client - **Versiune:** WinNUT-Client-2.x @@ -185,6 +190,16 @@ format manifest, workflow publicare, riscuri backup). - **Network Level Authentication:** Enabled - **Acces:** `mstsc /v:roacentral` (din rețea locală) +#### 6. OpenSSH Server +- **Serviciu:** `sshd` (OpenSSH_for_Windows_9.5), port 22, DefaultShell = PowerShell +- **Acces admin din claude-agent (LXC 171):** `ssh romfast@10.0.20.122 ''` + (sesiune elevată — High Mandatory Level) +- **Chei admin:** `C:\ProgramData\ssh\administrators_authorized_keys` + (ACL strict: doar `SYSTEM:F` + `BUILTIN\Administrators:F`, owner `BUILTIN\Administrators`, + fără moștenire — altfel sshd respinge/resetează) +- **Notă:** `Administrator` peste SSH dă „Connection reset" (cont dezactivat) → folosește `romfast`. + Guest agent QEMU are `guest-exec` dezactivat, deci SSH e singura cale de execuție remote. + --- ## 🌐 Configurare Rețea @@ -339,6 +354,12 @@ cd C:\Tools\win-acme iisreset ``` +> **Wildcard `*.roa.romfast.ro`:** NU se validează prin HTTP-01 (ca celelalte), ci prin +> **DNS-01** cu hook-ul cPanel `cpanel-acme-dns.ps1`. Dacă expiră, verifică renewal-ul +> `roa-wildcard-cpanel` (`.\wacs.exe --list`) și config-ul `cpanel-dns.config.json`. +> Sentinel în monitorizare: `efactura.roa.romfast.ro`. Detalii: secțiunea „Wildcard … +> Reînnoire Automată DNS-01 (cPanel)" din `vm201-certificat-letsencrypt-iis.md`. + **Documentație completă:** `vm201-certificat-letsencrypt-iis.md` --- @@ -408,6 +429,7 @@ ssh root@10.0.20.201 "qm delsnapshot 201 pre-update-snapshot" - **Verificare certificate (Windows):** `scripts/check-ssl-certificates.ps1` - **Monitorizare certificate (Proxmox):** `scripts/monitor-ssl-certificates.sh` - **Setup site-uri IIS noi (Dokploy):** `scripts/setup-new-iis-sites.ps1` +- **Hook DNS-01 wildcard cPanel (win-ACME):** `scripts/cpanel-acme-dns.ps1` (+ template `cpanel-dns.config.example.json`; config-ul real cu token NU e în git) ### Configurații IIS - **web.config roa-qr.romfast.ro:** `iis-configs/roa-qr.web.config` @@ -514,6 +536,6 @@ qm migrate 201 pvemini --online --- -**Ultima actualizare:** 2026-03-02 +**Ultima actualizare:** 2026-06-25 **Autor:** Marius Mutu **Proiect:** ROMFASTSQL - VM 201 Documentation diff --git a/proxmox/vm201-windows/docs/vm201-certificat-letsencrypt-iis.md b/proxmox/vm201-windows/docs/vm201-certificat-letsencrypt-iis.md index 66a8c9a..4048b3e 100644 --- a/proxmox/vm201-windows/docs/vm201-certificat-letsencrypt-iis.md +++ b/proxmox/vm201-windows/docs/vm201-certificat-letsencrypt-iis.md @@ -249,6 +249,66 @@ Get-ScheduledTask | Where-Object {$_.TaskName -like "*SSL*" -or $_.TaskName -lik grep ssl /etc/crontab ``` +## Wildcard `*.roa.romfast.ro` — Reînnoire Automată DNS-01 (cPanel) + +> **Incident 2026-05-31:** wildcardul a expirat. Renewal-ul win-acme era de tip +> `[Manual]` (DNS-01 cu TXT pus de mână) → nu rula din Scheduled Task → ~60 de +> erori consecutive → expirare. Subdomeniile Dokploy (`efactura.roa…`, `space.roa…`) +> dădeau `ERR_CERT_DATE_INVALID`. Monitorizarea nu prindea pentru că `*.roa` nu era +> în lista de domenii verificate (era doar `roa.romfast.ro`, un cert separat). + +Wildcardul **nu** poate folosi HTTP-01 (ca site-urile 1–6); necesită **DNS-01**. +DNS-ul `romfast.ro` e pe **hosting.com (cPanel)**, fără plugin nativ în win-acme, +deci folosim hook-ul de script `cpanel-acme-dns.ps1` care pune/șterge TXT-ul prin +cPanel API. + +### Setup (o singură dată, pe VM 201 ca Administrator) + +1. cPanel → **Manage API Tokens** → creează token cu drept de editare DNS. +2. Copiază pe VM 201: + - `scripts/cpanel-acme-dns.ps1` → `C:\Tools\win-acme\cpanel-acme-dns.ps1` + - `scripts/cpanel-dns.config.example.json` → `C:\Tools\win-acme\cpanel-dns.config.json` + și completează `Hostname`, `User`, `ApiToken`. **Nu commite** fișierul real + (e în `.gitignore`). +3. Test manual: + ```powershell + cd C:\Tools\win-acme + .\cpanel-acme-dns.ps1 create _acme-challenge.roa.romfast.ro testvalue123 + # verifică TXT-ul în cPanel Zone Editor, apoi: + .\cpanel-acme-dns.ps1 delete _acme-challenge.roa.romfast.ro testvalue123 + ``` +4. Recreează renewal-ul wildcard: + ``` + .\wacs.exe (ca Administrator) + M → Create renewal (full options) + Source: Manual → *.roa.romfast.ro + Validation: dns-01 → "Create verification records with your own script" + Run create: Powershell.exe -File C:\Tools\win-acme\cpanel-acme-dns.ps1 + Create args : create {RecordName} {Token} + Run delete: Powershell.exe -File C:\Tools\win-acme\cpanel-acme-dns.ps1 + Delete args : delete {RecordName} {Token} + Store: Windows Certificate Store (My) + Installation: IIS → site "roa-apps" (re-leagă certul automat pe binding) + ``` +5. Confirmă Scheduled task (rulând `wacs.exe` ca admin) → reînnoire 100% automată. + +### Verificare +```bash +echo | openssl s_client -connect efactura.roa.romfast.ro:443 \ + -servername efactura.roa.romfast.ro 2>/dev/null | openssl x509 -noout -dates +``` + +### Monitorizare +`monitor-ssl-certificates.sh` include acum `efactura.roa.romfast.ro` ca **sentinel** +pentru wildcard (Site ID `WILDCARD`). Dacă expiră, scriptul **alertează** (nu mai +încearcă auto-renew prin guest-exec, care e dezactivat) → intervenție pe VM 201. + +> **Securitate:** tokenul cPanel ajunge pe VM 201. Dă-i drepturi cât mai granulare +> (doar DNS). Dacă panoul e vechi și expune doar API2 ZoneEdit, setează +> `"LegacyZoneEdit": true` în config. + +--- + ## Note Importante - **SNI este OBLIGATORIU** pentru multiple certificate pe același IP:port diff --git a/proxmox/vm201-windows/scripts/cpanel-acme-dns.ps1 b/proxmox/vm201-windows/scripts/cpanel-acme-dns.ps1 new file mode 100644 index 0000000..72c35ee --- /dev/null +++ b/proxmox/vm201-windows/scripts/cpanel-acme-dns.ps1 @@ -0,0 +1,192 @@ +<# +.SYNOPSIS + win-acme DNS-01 validation hook for cPanel-hosted zones (hosting.com). + +.DESCRIPTION + Creates / deletes the _acme-challenge TXT record via the cPanel API so the + wildcard certificate *.roa.romfast.ro renews FULLY UNATTENDED (no manual TXT). + + Replaces the old "[Manual]" validation of the wildcard renewal in win-acme, + which could not run from the scheduled task and accumulated 60+ errors until + the certificate expired (incident 2026-05-31, see README). + + Authentication uses a cPanel API token (cPanel -> Manage API Tokens), passed + as the header: Authorization: cpanel : + + Modern panels (cPanel & WHM v90+) -> UAPI DNS::mass_edit_zone (default). + Legacy panels (only API2 ZoneEdit) -> set "LegacyZoneEdit": true in config. + +.PARAMETER Action + create | delete (win-acme calls the script once for each) + +.PARAMETER RecordName + Full record name, e.g. _acme-challenge.roa.romfast.ro (win-acme {RecordName}) + +.PARAMETER Token + TXT value to publish, supplied by the ACME server (win-acme {Token}) + +.NOTES + Place on VM 201 at: C:\Tools\win-acme\cpanel-acme-dns.ps1 + Credentials live in: C:\Tools\win-acme\cpanel-dns.config.json (NEVER commit) + Template: cpanel-dns.config.example.json + + Configure the renewal with "wacs.exe" (as Administrator): + M -> Create renewal (full options) + Source: Manual -> *.roa.romfast.ro + Validation: dns-01 -> "Create verification records with your own script" + Run create: Powershell.exe -File C:\Tools\win-acme\cpanel-acme-dns.ps1 + Create args : create {RecordName} {Token} + Run delete: Powershell.exe -File C:\Tools\win-acme\cpanel-acme-dns.ps1 + Delete args : delete {RecordName} {Token} + Store: Windows Certificate Store (My) + Installation: IIS -> site "roa-apps" (re-binds the cert automatically) + + Manual test before wiring into win-acme: + .\cpanel-acme-dns.ps1 create _acme-challenge.roa.romfast.ro testvalue123 + (check the TXT appears in cPanel Zone Editor, then:) + .\cpanel-acme-dns.ps1 delete _acme-challenge.roa.romfast.ro testvalue123 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('create', 'delete')][string]$Action, + [Parameter(Mandatory)][string]$RecordName, + [Parameter(Mandatory)][string]$Token +) + +$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# --- load config ----------------------------------------------------------- +$cfgPath = Join-Path $PSScriptRoot 'cpanel-dns.config.json' +if (-not (Test-Path $cfgPath)) { + throw "Missing config: $cfgPath (copy cpanel-dns.config.example.json and fill it in)" +} +$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json + +# Tolerate Hostname pasted as a URL ("https://host/cpanel", "host:2083", ...): +# keep only the bare hostname. +$cpHost = ($cfg.Hostname -replace '^\s*https?://', '') -replace '/.*$', '' -replace ':\d+\s*$', '' +$cpPort = if ($cfg.Port) { $cfg.Port } else { 2083 } +$cpUser = $cfg.User +$cpTok = $cfg.ApiToken +$ttl = if ($cfg.Ttl) { [int]$cfg.Ttl } else { 300 } +$legacy = [bool]$cfg.LegacyZoneEdit + +if (-not $cpHost -or -not $cpUser -or -not $cpTok) { + throw "Config incomplete: Hostname, User and ApiToken are required in $cfgPath" +} + +$headers = @{ Authorization = "cpanel ${cpUser}:${cpTok}" } +$base = "https://${cpHost}:${cpPort}" +$dname = $RecordName.TrimEnd('.') + '.' # cPanel wants FQDN with trailing dot + +function Invoke-Uapi { + param([string]$Module, [string]$Func, [hashtable]$Params) + $qs = ($Params.GetEnumerator() | ForEach-Object { + "$($_.Key)=$([uri]::EscapeDataString([string]$_.Value))" + }) -join '&' + $uri = "$base/execute/$Module/$Func" + if ($qs) { $uri += "?$qs" } + $r = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get + if ($r.errors) { throw "cPanel UAPI ${Module}::${Func} -> $($r.errors -join '; ')" } + return $r +} + +# Find the apex zone managed by cPanel that the record belongs to. +# _acme-challenge.roa.romfast.ro -> tries roa.romfast.ro, then romfast.ro +function Resolve-Zone { + param([string]$fqdn) + $labels = $fqdn.TrimEnd('.').Split('.') + for ($i = 1; $i -le $labels.Count - 2; $i++) { + $cand = ($labels[$i..($labels.Count - 1)]) -join '.' + try { + $r = Invoke-Uapi 'DNS' 'parse_zone' @{ zone = $cand } + if ($r.data) { return [pscustomobject]@{ Zone = $cand; Data = $r.data } } + } + catch { } + } + throw "No cPanel-managed zone found for $fqdn" +} + +function Get-ZoneSerial { + param($zoneData) + foreach ($l in $zoneData) { + if ($l.type -eq 'record' -and $l.record_type -eq 'SOA') { + $vals = $l.data_b64 | ForEach-Object { + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_)) + } + return [int64]$vals[2] # SOA: primary, hostmaster, SERIAL, refresh, ... + } + } + throw 'SOA serial not found in parsed zone' +} + +# --------------------------------------------------------------------------- +# Legacy path: cPanel API2 ZoneEdit (only if panel lacks UAPI DNS module) +# --------------------------------------------------------------------------- +if ($legacy) { + $z = Resolve-Zone $RecordName + $name = $RecordName.TrimEnd('.') + if ($Action -eq 'create') { + $uri = "$base/json-api/cpanel?cpanel_jsonapi_user=$cpUser&cpanel_jsonapi_apiversion=2" + + "&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=add_zone_record" + + "&domain=$($z.Zone)&name=$([uri]::EscapeDataString($name))&type=TXT" + + "&txtdata=$([uri]::EscapeDataString($Token))&ttl=$ttl" + $r = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get + if ($r.cpanelresult.error) { throw $r.cpanelresult.error } + Write-Host "[create] TXT $name added to $($z.Zone) (legacy ZoneEdit)" + } + else { + $look = "$base/json-api/cpanel?cpanel_jsonapi_user=$cpUser&cpanel_jsonapi_apiversion=2" + + "&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=fetchzone_records&domain=$($z.Zone)&type=TXT" + $r = Invoke-RestMethod -Uri $look -Headers $headers -Method Get + $hits = @($r.cpanelresult.data | Where-Object { + ($_.name.TrimEnd('.') -eq $name) -and ($_.txtdata -eq $Token) + } | Sort-Object { [int]$_.line } -Descending) + foreach ($rec in $hits) { + $del = "$base/json-api/cpanel?cpanel_jsonapi_user=$cpUser&cpanel_jsonapi_apiversion=2" + + "&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=remove_zone_record&domain=$($z.Zone)&line=$($rec.line)" + Invoke-RestMethod -Uri $del -Headers $headers -Method Get | Out-Null + Write-Host "[delete] TXT line $($rec.line) removed from $($z.Zone) (legacy ZoneEdit)" + } + } + return +} + +# --------------------------------------------------------------------------- +# Modern path: UAPI DNS::mass_edit_zone (serial-checked, atomic) +# --------------------------------------------------------------------------- +if ($Action -eq 'create') { + $z = Resolve-Zone $RecordName + $serial = Get-ZoneSerial $z.Data + # mass_edit_zone wants 'add' as a raw JSON record object (NOT base64). + $rec = @{ dname = $dname; ttl = $ttl; record_type = 'TXT'; data = @($Token) } | ConvertTo-Json -Compress + Invoke-Uapi 'DNS' 'mass_edit_zone' @{ zone = $z.Zone; serial = $serial; add = $rec } | Out-Null + Write-Host "[create] TXT $dname = $Token added to zone $($z.Zone)" +} +else { + # Re-parse before each removal: line_index and serial shift after every edit. + for ($pass = 0; $pass -lt 10; $pass++) { + $z = Resolve-Zone $RecordName + $serial = Get-ZoneSerial $z.Data + # parse_zone returns names RELATIVE to the zone (e.g. "_acme-challenge.roa"), + # so match against both the absolute and the zone-relative form. + $want = $RecordName.TrimEnd('.') + $wantRel = $want -replace ('\.' + [regex]::Escape($z.Zone) + '$'), '' + $idx = $null + foreach ($l in $z.Data) { + if ($l.type -eq 'record' -and $l.record_type -eq 'TXT') { + $n = ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($l.dname_b64))).TrimEnd('.') + $d = $l.data_b64 | ForEach-Object { + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_)) + } + if ((($n -eq $want) -or ($n -eq $wantRel)) -and ($d -contains $Token)) { + $idx = $l.line_index; break + } + } + } + if ($null -eq $idx) { break } + Invoke-Uapi 'DNS' 'mass_edit_zone' @{ zone = $z.Zone; serial = $serial; remove = $idx } | Out-Null + Write-Host "[delete] TXT line $idx removed from zone $($z.Zone)" + } +} diff --git a/proxmox/vm201-windows/scripts/cpanel-dns.config.example.json b/proxmox/vm201-windows/scripts/cpanel-dns.config.example.json new file mode 100644 index 0000000..bf39577 --- /dev/null +++ b/proxmox/vm201-windows/scripts/cpanel-dns.config.example.json @@ -0,0 +1,9 @@ +{ + "_comment": "Copy to cpanel-dns.config.json on VM 201 (C:\\Tools\\win-acme\\). NEVER commit the real file - it holds the cPanel API token.", + "Hostname": "your-server.hosting.com", + "Port": 2083, + "User": "cpanel_account_username", + "ApiToken": "PASTE_CPANEL_API_TOKEN_HERE", + "Ttl": 300, + "LegacyZoneEdit": false +} diff --git a/proxmox/vm201-windows/scripts/monitor-ssl-certificates.sh b/proxmox/vm201-windows/scripts/monitor-ssl-certificates.sh index 6ab14d6..97b3eea 100644 --- a/proxmox/vm201-windows/scripts/monitor-ssl-certificates.sh +++ b/proxmox/vm201-windows/scripts/monitor-ssl-certificates.sh @@ -13,15 +13,22 @@ LOG_FILE="/var/log/ssl-monitor.log" EMAIL_TO="root" # Proxmox trimite la adresa configurata # Domenii de verificat +# NOTA: efactura.roa.romfast.ro este un SENTINEL pentru certificatul wildcard +# *.roa.romfast.ro. Wildcardul nu poate fi testat direct, asa ca verificam +# un subdomeniu real acoperit de el. Site ID "WILDCARD" => doar ALERTA, +# fara auto-renew (wildcardul e DNS-01, reinnoit de cpanel-acme-dns.ps1 pe +# VM 201; auto-renew prin guest-exec nu mai functioneaza - exec dezactivat). +# Context: incident expirare wildcard 2026-05-31 (vezi README VM 201). DOMAINS=( "roa.romfast.ro" "dokploy.romfast.ro" "gitea.romfast.ro" "roa2web.romfast.ro" + "efactura.roa.romfast.ro" ) # Site IDs pentru fiecare domeniu (in aceeasi ordine) -SITE_IDS=(1 2 3 4) +SITE_IDS=(1 2 3 4 "WILDCARD") log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" @@ -52,6 +59,14 @@ force_renew_certificate() { local site_id=$1 local domain=$2 + # Wildcard (*.roa) = DNS-01, reinnoit automat de cpanel-acme-dns.ps1 pe VM 201. + # Nu incercam auto-renew aici (nu e HTTP-01/siteid si guest-exec e dezactivat) - + # doar semnalam ca a expirat ca sa intervina cineva pe VM 201. + if ! [[ "$site_id" =~ ^[0-9]+$ ]]; then + log "ALERTA: $domain (wildcard *.roa) necesita interventie manuala pe VM 201 - verifica renewal-ul win-acme cu validare cPanel (cpanel-acme-dns.ps1)" + return 1 + fi + log "Fortez reinstalare certificat pentru $domain (Site ID: $site_id)..." # Executa pe VM 201 prin Proxmox guest agent