Plugins/HostingNL.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]$HNLToken,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $HNLTokenInsecure = [pscredential]::new('a', $HNLToken).GetNetworkCredential().Password

    # Normalize the TxtValue to ensure it is wrapped in quotes
    if ($TxtValue -notmatch '^".*"$') {
        $TxtValue = "`"$TxtValue`""
    }

    $zone = Find-HNLZone -RecordName $RecordName -Token $HNLTokenInsecure

    $rec = Invoke-HNLRest 'GET' "domains/$zone/dns" $HNLTokenInsecure |
        Select-Object -ExpandProperty data |
        Where-Object {
            $_.type -eq 'TXT' -and
            $_.name -eq $RecordName -and
            $_.content -eq $TxtValue
        }

    if ($rec) {
        Write-Debug "Record $RecordName with value $TxtValue already exists. Nothing to do."
        return
    }

    # create the record (must be an array, even if only one)
    $body = ConvertTo-Json -InputObject @(@{
        name    = $RecordName
        type    = 'TXT'
        content = $TxtValue
        ttl     = 300
    })
    $null = Invoke-HNLRest 'POST' "domains/$zone/dns" $HNLTokenInsecure -Body $body

    <#
    .SYNOPSIS
        Add a DNS TXT record to Hosting.nl.

    .DESCRIPTION
        Finds the zone that owns RecordName, checks whether a matching TXT
        record already exists, and if not, creates one via the Hosting.nl API.

    .PARAMETER RecordName
        The fully qualified name of the TXT record.

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER HNLToken
        The Hosting.nl API token, as a SecureString.

    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.

    .EXAMPLE
        $token = Read-Host 'HostingNL API Token' -AsSecureString
        Add-DnsTxt '_acme-challenge.example.com' 'txt-value' -HNLToken $token

        Adds the record using a SecureString token.
    #>

}

function Remove-DnsTxt {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [securestring]$HNLToken,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $HNLTokenInsecure = [pscredential]::new('a', $HNLToken).GetNetworkCredential().Password

    # Normalize the TxtValue to ensure it is wrapped in quotes
    if ($TxtValue -notmatch '^".*"$') {
        $TxtValue = "`"$TxtValue`""
    }

    $zone = Find-HNLZone -RecordName $RecordName -Token $HNLTokenInsecure

    $rec = Invoke-HNLRest 'GET' "domains/$zone/dns" $HNLTokenInsecure |
        Select-Object -ExpandProperty data |
        Where-Object {
            $_.type -eq 'TXT' -and
            $_.name -eq $RecordName -and
            $_.content -eq $TxtValue
        }

    if (-not $rec) {
        Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do."
        return
    }

    $body = ConvertTo-Json -InputObject @(@{ id = $rec.id })
    $null = Invoke-HNLRest 'DELETE' "domains/$zone/dns" $HNLTokenInsecure -Body $body

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Hosting.nl.

    .DESCRIPTION
        Finds the zone that owns RecordName, looks up the matching TXT
        record by name and value, and deletes it by id via the Hosting.nl API.

    .PARAMETER RecordName
        The fully qualified name of the TXT record.

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER HNLToken
        The Hosting.nl API token, as a SecureString.

    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.

    .EXAMPLE
        $token = Read-Host 'HostingNL API Token' -AsSecureString
        Remove-DnsTxt '_acme-challenge.example.com' 'txt-value' -HNLToken $token

        Removes the record using a SecureString token.
    #>

}

function Save-DnsTxt {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )
    <# Not required. #>
}


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

# API docs: https://api.hosting.nl/api/documentation

# Wraps Invoke-RestMethod with the Hosting.nl auth header and error shape.
# Hosting.nl reports errors as either {"error": "..."} or
# {"errors": {"message": "..."}} in the response body rather than through
# HTTP status text alone, so a plain Invoke-RestMethod failure would surface
# a generic "400 Bad Request" instead of the actual reason. This pulls that
# message out of the error response and rethrows it, falling back to the raw
# error if the body doesn't match either shape.
function Invoke-HNLRest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)][string]$Method,
        [Parameter(Mandatory,Position=1)][string]$Path,
        [Parameter(Mandatory,Position=2)][string]$Token,
        [string]$Body
    )

    $params = @{
        Uri         = "https://api.hosting.nl/$Path"
        Method      = $Method
        Headers     = @{
            'API-TOKEN' = $Token
            'Accept'    = 'application/json'
        }
        ErrorAction = 'Stop'
        Verbose     = $false
        Debug       = $false
    }
    if ($Body) {
        $params.Body = $Body
        $params.ContentType = 'application/json'
    }

    try {
        Write-Debug "$Method $($params.Uri)"
        if ($Body) { Write-Debug $Body }
        return Invoke-RestMethod @params @script:UseBasic
    } catch {
        throw
    }

    <#
    .SYNOPSIS
        Calls the Hosting.nl REST API.

    .DESCRIPTION
        Wraps Invoke-RestMethod with the API-TOKEN header and JSON content type,
        serializes any body to a JSON array, and on failure rethrows the message
        Hosting.nl returns in the response body instead of a generic HTTP status.

    .PARAMETER Method
        The HTTP method: Get, Post, or Delete.

    .PARAMETER Path
        The request path appended to https://api.hosting.nl, for example /domains.

    .PARAMETER Token
        The Hosting.nl API token, as plaintext.

    .PARAMETER Body
        Optional request body (a PowerShell object or array) sent as JSON.

    .OUTPUTS
        The parsed response object.
    #>

}

function Find-HNLZone {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$Token
    )

    if (-not $script:HNLRecordZones) { $script:HNLRecordZones = @{} }

    if ($script:HNLRecordZones.ContainsKey($RecordName)) {
        return $script:HNLRecordZones[$RecordName]
    }

    # We need to find the closest/deepest sub-zone that would hold
    # the record rather than just adding it to the apex.
    $pieces = $RecordName.Split('.')
    for ($i=0; $i -lt ($pieces.Count-1); $i++) {
        $zoneTest = $pieces[$i..($pieces.Count-1)] -join '.'
        Write-Debug "Checking $zoneTest"

        try {
            $null = Invoke-HNLRest GET "domains/$zoneTest" $Token
            # No error means the zone exists, so cache it and return it
            # We don't need the save the ID because none of the other calls
            # we'll be using need it.
            Write-Debug "Cached zone $zoneTest for $RecordName"
            $script:HNLRecordZones[$RecordName] = $zoneTest
            return $zoneTest
        } catch {
            # 404 is expected if the zone doesn't exist. Re-throw any other error.
            if ($_.Exception.StatusCode -eq 404) {
                continue
            } else {
                Write-Debug "Error $($_.Exception.StatusCode)"
            }
            throw
        }
    }

    # If we get here, we didn't find a matching zone.
    throw "No zone in this account matches $RecordName"
}