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

    # dns-01 values arrive bare, but dns-persist-01 values arrive already wrapped in
    # double quotes because they contain spaces. The API stores exactly one pair of
    # quotes either way, so normalize the incoming value to the quoted form and compare
    # against the stored target as-is.
    if ($TxtValue -notmatch '^".*"$') {
        $TxtValue = '"{0}"' -f $TxtValue
    }

    $apiRoot = 'https://api.infomaniak.com/2/zones'

    # grab the plain text token
    $tokenPlain = [pscredential]::new('a',$InfomaniakToken).GetNetworkCredential().Password

    # build the common parameters for all API calls
    $commonParams = @{
        Headers = @{
            Authorization = "Bearer $tokenPlain"
            Accept        = 'application/json'
        }
        ErrorAction = 'Stop'
        Verbose = $false
        Debug = $false
    } + $script:UseBasic

    if (-not ($zone = Find-IKV2Zone $RecordName $commonParams $apiRoot)) {
        throw "Unable to find matching zone for $RecordName."
    }

    # separate the portion of the name that doesn't contain the zone name
    $recShort = $RecordName -ireplace "\.?$([regex]::Escape($zone.TrimEnd('.')))$",''
    # the API represents an apex record with a bare dot
    if ([String]::IsNullOrEmpty($recShort)) { $recShort = '.' }

    if (Find-IKV2TxtRecord $zone $recShort $RecordName $TxtValue $commonParams $apiRoot) {
        Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do."
        return
    }

    try {
        Write-Verbose "Adding a TXT record for $RecordName with value $TxtValue"
        $queryParams = @{
            Uri = "$apiRoot/$zone/records"
            Method = 'POST'
            Body = @{
                type   = 'TXT'
                source = $recShort
                target = $TxtValue
                ttl    = 600
            } | ConvertTo-Json -Compress
            ContentType = 'application/json'
        } + $commonParams
        Write-Debug "POST $($queryParams.Uri)`n$($queryParams.Body)"
        Invoke-RestMethod @queryParams | Out-Null
    } catch { throw }

    <#
    .SYNOPSIS
        Add a DNS TXT record to Infomaniak using their v2 API.

    .DESCRIPTION
        Add a DNS TXT record to Infomaniak using their v2 API.

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER InfomaniakToken
        The API token for your Infomaniak account. It must have the 'dns:read' and 'dns:write' scopes.

    .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 'Token' -AsSecureString
        Add-DnsTxt '_acme-challenge.example.com' 'txt-value' $token

        Adds the specified TXT record with the specified value.
    #>

}

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

    # dns-01 values arrive bare, but dns-persist-01 values arrive already wrapped in
    # double quotes because they contain spaces. The API stores exactly one pair of
    # quotes either way, so normalize the incoming value to the quoted form and compare
    # against the stored target as-is.
    if ($TxtValue -notmatch '^".*"$') {
        $TxtValue = '"{0}"' -f $TxtValue
    }

    $apiRoot = 'https://api.infomaniak.com/2/zones'

    # grab the plain text token
    $tokenPlain = [pscredential]::new('a',$InfomaniakToken).GetNetworkCredential().Password

    # build the common parameters for all API calls
    $commonParams = @{
        Headers = @{
            Authorization = "Bearer $tokenPlain"
            Accept        = 'application/json'
        }
        ErrorAction = 'Stop'
        Verbose = $false
        Debug = $false
    } + $script:UseBasic

    if (-not ($zone = Find-IKV2Zone $RecordName $commonParams $apiRoot)) {
        throw "Unable to find matching zone for $RecordName."
    }

    # separate the portion of the name that doesn't contain the zone name
    $recShort = $RecordName -ireplace "\.?$([regex]::Escape($zone.TrimEnd('.')))$",''
    # the API represents an apex record with a bare dot
    if ([String]::IsNullOrEmpty($recShort)) { $recShort = '.' }

    $rec = Find-IKV2TxtRecord $zone $recShort $RecordName $TxtValue $commonParams $apiRoot

    if (-not $rec) {
        Write-Debug "Could not find record $RecordName to delete. Nothing to do."
        return
    }

    try {
        Write-Verbose "Removing TXT record for $RecordName with value $TxtValue"
        $queryParams = @{
            Uri = "$apiRoot/$zone/records/$($rec.id)"
            Method = 'DELETE'
        } + $commonParams
        Write-Debug "DELETE $($queryParams.Uri)"
        Invoke-RestMethod @queryParams | Out-Null
    } catch { throw }

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Infomaniak using their v2 API.

    .DESCRIPTION
        Remove a DNS TXT record from Infomaniak using their v2 API.

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER InfomaniakToken
        The API token for your Infomaniak account. It must have the 'dns:read' and 'dns:write' scopes.

    .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 'Token' -AsSecureString
        Remove-DnsTxt '_acme-challenge.example.com' 'txt-value' $token

        Removes the specified TXT record with the specified value.
    #>

}

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.
    #>

}

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

# API Docs: https://developer.infomaniak.com/docs/api/get/2/zones/{zone}/records

function Find-IKV2Zone {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$RecordName,
        [Parameter(Mandatory, Position = 1)]
        [hashtable]$CommonParams,
        [Parameter(Mandatory, Position = 2)]
        [string]$ApiRoot
    )

    # setup a module variable to cache the record to zone mapping
    # so it's quicker to find later
    if (!$script:IKV2RecordZones) { $script:IKV2RecordZones = @{} }

    # check for the record in the cache
    if ($script:IKV2RecordZones.ContainsKey($RecordName)) {
        return $script:IKV2RecordZones.$RecordName
    }

    # Unlike the v1 API, v2 addresses zones by name rather than by a numeric product ID,
    # so we can ask about a candidate zone directly instead of searching the product list.
    # We still 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 {
            $queryParams = @{
                Uri = "$ApiRoot/$zoneTest/exists"
            } + $CommonParams
            Write-Debug "GET $($queryParams.Uri)"
            $response = Invoke-RestMethod @queryParams
        } catch {
            # A zone that isn't in the account returns 404 with an 'object_not_found'
            # error body. Ignore those and re-throw anything else.
            if ($_.Exception.Response.StatusCode -ne 404) {
                throw
            }
            continue
        }

        if ($response.result -eq 'success' -and $response.data) {
            $script:IKV2RecordZones.$RecordName = $zoneTest
            return $zoneTest
        }
    }

    return $null
}

function Find-IKV2TxtRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Zone,
        [Parameter(Mandatory, Position = 1)]
        [string]$RecShort,
        [Parameter(Mandatory, Position = 2)]
        [string]$RecordName,
        [Parameter(Mandatory, Position = 3)]
        [string]$TxtValue,
        [Parameter(Mandatory, Position = 4)]
        [hashtable]$CommonParams,
        [Parameter(Mandatory, Position = 5)]
        [string]$ApiRoot
    )

    # 'with=idn' adds the source_idn field, which holds the fully qualified record name,
    # so the name comparison below can match either the relative or the fully qualified
    # form. Separately, the API always returns TXT targets wrapped in a single pair of
    # double quotes, which is why callers normalize TxtValue to the quoted form first.
    try {
        $queryParams = @{
            Uri = "$ApiRoot/$Zone/records?with=idn"
        } + $CommonParams
        Write-Debug "GET $($queryParams.Uri)"
        $recs = @(Invoke-RestMethod @queryParams | Select-Object -ExpandProperty data)
    } catch { throw }

    $recs | Where-Object {
        $_.type -eq 'TXT' -and
        ($_.source -eq $RecShort -or $_.source_idn -eq $RecordName) -and
        $_.target -eq $TxtValue
    } | Select-Object -First 1
}