<# .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)" } }