feat(vm201): wildcard *.roa auto-renew via cPanel DNS-01 hook

Fix expirare cert wildcard *.roa.romfast.ro (incident 2026-05-31):
renewal-ul era [Manual] DNS-01, nu rula din Scheduled Task -> 61 erori
-> expirat. Subdomeniile Dokploy (efactura.roa etc.) dadeau
ERR_CERT_DATE_INVALID.

- cpanel-acme-dns.ps1: hook win-ACME DNS-01 (cPanel UAPI mass_edit_zone,
  fallback ZoneEdit) care pune/sterge TXT _acme-challenge automat
- cpanel-dns.config.example.json: template (token-ul real e gitignored)
- monitor-ssl-certificates.sh: sentinel efactura.roa (wildcard) + alerta
  in loc de auto-renew prin guest-exec (dezactivat)
- README + doc cert: flux DNS-01 cPanel + acces OpenSSH VM 201

Renewal nou roa-wildcard-cpanel, auto, due 2026-08-19; vechiul [Manual]
anulat. Cert live valid pana 2026-09-23.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-25 13:23:54 +00:00
parent e8d1889364
commit a41e9d81cf
6 changed files with 305 additions and 3 deletions

4
.gitignore vendored
View File

@@ -12,3 +12,7 @@ input/
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
# Secrets - real cPanel DNS hook config holds an API token (template is committed)
cpanel-dns.config.json
**/cpanel-dns.config.json

View File

@@ -166,7 +166,12 @@ format manifest, workflow publicare, riscuri backup).
- **Locație:** `C:\Tools\win-acme\` - **Locație:** `C:\Tools\win-acme\`
- **Scop:** Certificate Let's Encrypt automate - **Scop:** Certificate Let's Encrypt automate
- **Task Scheduler:** Reînnoire automată zilnică (verificare) - **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 #### 4. WinNUT Client
- **Versiune:** WinNUT-Client-2.x - **Versiune:** WinNUT-Client-2.x
@@ -185,6 +190,16 @@ format manifest, workflow publicare, riscuri backup).
- **Network Level Authentication:** Enabled - **Network Level Authentication:** Enabled
- **Acces:** `mstsc /v:roacentral` (din rețea locală) - **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 '<powershell>'`
(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 ## 🌐 Configurare Rețea
@@ -339,6 +354,12 @@ cd C:\Tools\win-acme
iisreset 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` **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` - **Verificare certificate (Windows):** `scripts/check-ssl-certificates.ps1`
- **Monitorizare certificate (Proxmox):** `scripts/monitor-ssl-certificates.sh` - **Monitorizare certificate (Proxmox):** `scripts/monitor-ssl-certificates.sh`
- **Setup site-uri IIS noi (Dokploy):** `scripts/setup-new-iis-sites.ps1` - **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 ### Configurații IIS
- **web.config roa-qr.romfast.ro:** `iis-configs/roa-qr.web.config` - **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 **Autor:** Marius Mutu
**Proiect:** ROMFASTSQL - VM 201 Documentation **Proiect:** ROMFASTSQL - VM 201 Documentation

View File

@@ -249,6 +249,66 @@ Get-ScheduledTask | Where-Object {$_.TaskName -like "*SSL*" -or $_.TaskName -lik
grep ssl /etc/crontab 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 16); 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 ## Note Importante
- **SNI este OBLIGATORIU** pentru multiple certificate pe același IP:port - **SNI este OBLIGATORIU** pentru multiple certificate pe același IP:port

View File

@@ -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 <USER>:<TOKEN>
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)"
}
}

View File

@@ -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
}

View File

@@ -13,15 +13,22 @@ LOG_FILE="/var/log/ssl-monitor.log"
EMAIL_TO="root" # Proxmox trimite la adresa configurata EMAIL_TO="root" # Proxmox trimite la adresa configurata
# Domenii de verificat # 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=( DOMAINS=(
"roa.romfast.ro" "roa.romfast.ro"
"dokploy.romfast.ro" "dokploy.romfast.ro"
"gitea.romfast.ro" "gitea.romfast.ro"
"roa2web.romfast.ro" "roa2web.romfast.ro"
"efactura.roa.romfast.ro"
) )
# Site IDs pentru fiecare domeniu (in aceeasi ordine) # Site IDs pentru fiecare domeniu (in aceeasi ordine)
SITE_IDS=(1 2 3 4) SITE_IDS=(1 2 3 4 "WILDCARD")
log() { log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 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 site_id=$1
local domain=$2 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)..." log "Fortez reinstalare certificat pentru $domain (Site ID: $site_id)..."
# Executa pe VM 201 prin Proxmox guest agent # Executa pe VM 201 prin Proxmox guest agent