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:
192
proxmox/vm201-windows/scripts/cpanel-acme-dns.ps1
Normal file
192
proxmox/vm201-windows/scripts/cpanel-acme-dns.ps1
Normal 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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user