Plugins/Porkbun.ps1

function Get-CurrentPluginType { 'dns-01' }

function Add-DnsTxt
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [string] $RecordName,
        [Parameter(Mandatory, Position = 1)]
        [string] $TxtValue,
        [Parameter(Mandatory, Position = 2)]
        [SecureString] $PorkbunAPIKey,
        [Parameter(Mandatory, Position = 3)]
        [SecureString] $PorkbunSecret,
        [string] $PorkbunAPIHost = 'api.porkbun.com',
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    [string] $APIKey = [PSCredential]::new('Username', $PorkbunAPIKey).GetNetworkCredential().Password
    [string] $APISecret = [PSCredential]::new('Username', $PorkbunSecret).GetNetworkCredential().Password

    $domainQuery = @{
        LongName = $RecordName
        PorkbunAPIKey = $APIKey
        PorkbunSecret = $APISecret
        PorkbunAPIHost = $PorkbunAPIHost
    }
    $DomainInfo = Get-PorkbunDomainInfo @domainQuery

    # Get the portion of the full name that is the domain name (e.g. 'record.name.sub.example.com' will become 'example.com')
    [string] $DomainName = $DomainInfo.Domain
    # Get the portion of the full name that will become the record name (e.g. 'record.name.sub.example.com' will become 'record.name.sub')
    [string] $RecordNameShort = ($RecordName -ireplace [Regex]::Escape($DomainName), [string]::Empty).TrimEnd('.')

    # Get any existing TXT record(s) that already match what we want to create
    [object[]] $EqualRecords = @($DomainInfo.Records | Where-Object { ($_.type -EQ 'TXT') -AND ($_.name -eq $RecordName) -AND ($_.content -EQ $TxtValue) })

    if ($EqualRecords.Count -EQ 0)
    {
        Write-Debug 'This record does not exist yet, creating it'
        Write-Verbose "Creating record `"$RecordNameShort`" with value `"$TxtValue`" on domain `"$DomainName`""
        $queryParams = @{
            Uri = "https://$PorkbunAPIHost/api/json/v3/dns/create/$DomainName"
            Method = 'POST'
            BodyObject = @{
                name = $RecordNameShort
                type = 'TXT'
                content = $TxtValue
                apikey = $APIKey
                secretapikey = $APISecret
            }
        }
        try {
            $ResultData = Invoke-Porkbun @queryParams
            if ($ResultData.status -NE 'SUCCESS') { throw "API returned result $($ResultData.status)" }
            Write-Debug 'Successfully created record.'
        } catch {
            throw
        }
    }
    else
    {
        Write-Debug 'A record already exists with this value, so no creation is necessary'
        return
    }

    <#
    .SYNOPSIS
        Add a DNS TXT record to Porkbun.
    .DESCRIPTION
        Adds or edits a DNS TXT record using Porkbun's API.
    .PARAMETER RecordName
        The fully qualified name of the TXT record.
    .PARAMETER TxtValue
        The value of the TXT record.
    .PARAMETER PorkbunAPIKey
        The API key to use, obtained from https://porkbun.com/account/api
    .PARAMETER PorkbunSecret
        The API secret key corresponding to the API key, also obtained from https://porkbun.com/account/api (not accessible after being generated)
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
    .EXAMPLE
        Add-DnsTxt '_acme-challenge.example.com' 'txt-value'

        Adds a TXT record for the specified site with the specified value. Does nothing if the record already exists.
    #>

}

function Remove-DnsTxt
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [string] $RecordName,
        [Parameter(Mandatory, Position = 1)]
        [string] $TxtValue,
        [Parameter(Mandatory, Position = 2)]
        [SecureString] $PorkbunAPIKey,
        [Parameter(Mandatory, Position = 3)]
        [SecureString] $PorkbunSecret,
        [string] $PorkbunAPIHost = 'api.porkbun.com',
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    [string] $APIKey = [PSCredential]::new('Username', $PorkbunAPIKey).GetNetworkCredential().Password
    [string] $APISecret = [PSCredential]::new('Username', $PorkbunSecret).GetNetworkCredential().Password

    $domainQuery = @{
        LongName = $RecordName
        PorkbunAPIKey = $APIKey
        PorkbunSecret = $APISecret
        PorkbunAPIHost = $PorkbunAPIHost
    }
    $DomainInfo = Get-PorkbunDomainInfo @domainQuery

    # Get the portion of the full name that is the domain name (e.g. 'record.name.sub.example.com' will become 'example.com')
    [string] $DomainName = $DomainInfo.Domain

    # Get any existing TXT record(s) that have matching content
    [object[]] $EqualRecords = @($DomainInfo.Records | Where-Object { ($_.type -EQ 'TXT') -AND ($_.name -eq $RecordName) -AND ($_.content -EQ $TxtValue) })

    if ($EqualRecords.Count -EQ 0)
    {
        Write-Debug 'There are no records with this content, so no deletion is necessary'
        return
    }
    else
    {
        Write-Debug "Found $($EqualRecords.Count) record(s) to delete."
        foreach($RecordToDelete in $EqualRecords)
        {
            $RecordID = $RecordToDelete.id

            Write-Verbose "Deleting record ID `"$RecordID`" on domain `"$DomainName`""
            $queryParams = @{
                Uri = "https://$PorkbunAPIHost/api/json/v3/dns/delete/$DomainName/$RecordID"
                Method = 'POST'
                BodyObject = @{
                    apikey = $APIKey
                    secretapikey = $APISecret
                }
            }
            try {
                $ResultData = Invoke-Porkbun @queryParams
                if ($ResultData.status -NE 'SUCCESS') { throw "API returned result $($ResultData.status)" }
                Write-Debug 'Successfully deleted record.'
            } catch {
                throw
            }
        }
    }

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Porkbun.
    .DESCRIPTION
        Removes a DNS TXT record using Porkbun's API.
    .PARAMETER RecordName
        The fully qualified name of the TXT record.
    .PARAMETER TxtValue
        The value of the TXT record.
    .PARAMETER PorkbunAPIKey
        The API key to use, obtained from https://porkbun.com/account/api
    .PARAMETER PorkbunSecret
        The API secret key corresponding to the API key, also obtained from https://porkbun.com/account/api (not accessible after being generated)
    .EXAMPLE
        Remove-DnsTxt '_acme-challenge.example.com' 'txt-value'

        Removes a TXT record for the specified site with the specified value. Does nothing if the record does not exist.
    #>

}

function Save-DnsTxt
{
    [CmdletBinding()]
    param
    (
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    <#
    .SYNOPSIS
        Not required.
    .DESCRIPTION
        This provider does not require calling this function to commit changes to DNS records.
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
    .EXAMPLE
        Save-DnsTxt

        Commits changes for pending DNS TXT record modifications. (Not required)
    #>

}

##################################
# Helper Functions
##################################

# API Documentation: https://porkbun.com/api/json/v3/documentation

function Invoke-Porkbun
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
        [string]$Method,
        [hashtable]$BodyObject
    )

    # Porkbun seems to have some sort of rate limiting that results in HTTP 503 errors
    # when you hit it (or maybe it's just overloaded?). But there's no documentation
    # about it and there are no Retry-After headers in the response. So we're just
    # going to implement a dumb retry mechanic.
    $queryParams = @{
        Uri = $Uri
        Method = $Method
        Body = ($BodyObject | ConvertTo-Json)
        ErrorAction = 'Stop'
        Verbose = $false
    }

    # sanitize credentials for logging
    $BodyObject.apikey = 'REDACTED'
    $BodyObject.secretapikey = 'REDACTED'

    for ($i=0; $i -lt 3; $i++) {
        try {
            Write-Debug "POST $($queryParams.Uri)`n$($BodyObject|ConvertTo-Json)"
            $ResultData = Invoke-RestMethod @queryParams @script:UseBasic
            return $ResultData
        }
        catch {
            if ($_.Exception.Response.StatusCode -eq 503) {
                Write-Debug "API Temporarily Unavailable. Waiting and Re-trying."
                Start-Sleep -Seconds 2
                continue
            } else {
                throw
            }
        }
    }
    throw "API responding as Temporarily Unavailable. Gave up retrying."
}

function Get-PorkbunDomainInfo
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [string] $LongName,
        [Parameter(Mandatory)]
        [string] $PorkbunAPIKey,
        [Parameter(Mandatory)]
        [string] $PorkbunSecret,
        [string] $PorkbunAPIHost = 'api.porkbun.com'
    )

    Write-Debug "Looking for domain `"$LongName`""

    [string[]] $Sections = $LongName.Split('.')
    [int] $MaxIndex = $Sections.Count - 1
    for ([int]$i = 0; $i -lt $MaxIndex; $i++)
    {
        [string] $NameToCheck = [string]::Join('.', $Sections[$i .. $MaxIndex])
        Write-Debug "Querying API for `"$NameToCheck`""

        try {
            $queryParams = @{
                Uri = "https://$PorkbunAPIHost/api/json/v3/dns/retrieve/$NameToCheck"
                Method = 'POST'
                BodyObject = @{
                    apikey = $PorkbunAPIKey
                    secretapikey = $PorkbunSecret
                }
            }
            $ResultData = Invoke-Porkbun @queryParams
        }
        catch {
            if ($_.Exception.Response.StatusCode -eq 400) {
                Write-Debug "Could not find domain `"$NameToCheck`"."
                continue
            } else {
                Write-Debug "Something went wrong while checking domain `"$NameToCheck`""
                throw
            }
        }

        if ($ResultData.status -NE 'SUCCESS') {
            Write-Debug "API returned status $($ResultData.status) for domain `"$NameToCheck`""
            continue
        }

        Write-Debug "Found domain `"$NameToCheck`""
        $domainInfo = [pscustomobject]@{
            Domain = $NameToCheck
            Records = $ResultData.records
        }
        return $domainInfo
    }
    throw "No matching domain could be found for `"$LongName`" on this Porkbun account. Check that the domain is correct, that your API key and secret are entered correctly, and that you've enabled API access for this domain in the settings."

    <#
    .SYNOPSIS
        Finds the domain and existing records.
    .DESCRIPTION
        Uses the Porkbun API to find the relevant domain and existing records for this full name.
    .PARAMETER LongName
        The combined record/domain name to query for.
    .PARAMETER PorkbunAPIKey
        The API key to use, obtained from https://porkbun.com/account/api
    .PARAMETER PorkbunSecret
        The API secret key corresponding to the API key, also obtained from https://porkbun.com/account/api (not accessible after being generated)
    .OUTPUTS
        An object containing a 'Domain' property, which contains the base domain name, and a 'Records' property which contains all currently existing records for this domain, including ones for other subdomains.
    .EXAMPLE
        Get-PorkbunDomainInfo -PorkbunAPIKey (key) -PorkbunAPISecret (secret) -LongName 'long.name.for.example.com'
        Will return a Domain of 'example.com' and any records for that entire domain.
    #>

}