Plugins/TransIP.ps1

<#
.SYNOPSIS
    Posh-ACME DNS plugin for TransIP
 
.DESCRIPTION
    Implements DNS-01 for TransIP, supporting both private key authentication
    (as SecureString or PEM file) and JWT AccessToken.
    Compatible with Windows Powershell 5.1 and PowerShell 7+.
    Supports both PKCS#1 and TransIP's PKCS#8 key formats on all platforms.
    PKCS#8 key format support on WinPS5.1 requires .NET 4.7.2+ / Win10+ or Server2016+
#>


function Get-CurrentPluginType { 'dns-01' }

function Add-DnsTxt {
    [CmdletBinding(DefaultParameterSetName = 'KeyAuthWithSecureString')]
    param (
        # Standard DNS and config
        [Parameter(Mandatory, Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory, Position=1)]
        [string]$TxtValue,

        # Shared between KeyAuth sets
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPUsername,

        # Private Key as a SecureString
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [securestring]$TIPKeyText,
        # Private Key defined as a Filepath to a file
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPKeyPath,

        # Whether or not to enforce IP whitelisting for the key
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString')]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath')]
        [switch]$TIPEnforceWhitelist,

        # Supplying your own JWT auth token instead is also possible
        [Parameter(ParameterSetName = 'TokenAuth', Mandatory)]
        [string]$TIPAccessToken,

        [string]$TIPAPIEndpoint = "https://api.transip.nl/v6"
    )
    # Decide token source: supplied or generated
    if ($PSCmdlet.ParameterSetName -eq 'TokenAuth') {
        # Use supplied JWT TIPAccessToken directly
        $token = $TIPAccessToken
    } else {
        # Get new token using private key
        $token = Get-TransIPJwtToken -TIPUsername $TIPUsername -TIPKeyText $TIPKeyText -TIPKeyPath $TIPKeyPath -TIPEnforceWhitelist:$TIPEnforceWhitelist.IsPresent -TIPAPIEndpoint $TIPAPIEndpoint
    }
    # Find root domain for the record
    $RootDomain = Find-TransIPRootDomain -RecordName $RecordName -Token $token -TIPAPIEndpoint $TIPAPIEndpoint
    if (-not $RootDomain) { throw "Could not determine root domain for $RecordName" }
    # Strip root domain from record name for relative (sub)domain
    $RelativeName = Get-TransIPRelativeName -RecordName $RecordName -RootDomain $RootDomain

    # Query existing records to check for duplicates
    $queryParams = @{
        Uri = "$TIPAPIEndpoint/domains/$RootDomain/dns"
        Headers = @{ Authorization = "Bearer $token" }
        Verbose = $false
        ErrorAction = 'Stop'
    }
    try {
        Write-Debug "GET $($queryParams.Uri)"
        $result = Invoke-RestMethod @queryParams @script:UseBasic
    } catch { throw }
    $records = $result.dnsEntries

    # If a TXT record already exists with the same name+type, we need to use the
    # same TTL value on any new record we create.
    $ttl = 300
    $recMatch = @($records | Where-Object {
        $_.name -eq $RelativeName -and $_.type -eq 'TXT'
    })
    if ($recMatch) {
        $ttl = $recMatch[0].expire
        Write-Debug "Using TTL $ttl from existing record"
    }

    # If a TXT record for this value already exists, skip add
    if ($recMatch | Where-Object { $_.content -eq $TxtValue }) {
        Write-Verbose "TXT record '$RelativeName' with value '$TxtValue' already exists at $RootDomain. No action needed."
        return
    }

    # None exists, so create new TXT DNS entry
    $queryParams = @{
        Uri = $queryParams.Uri
        Method = 'POST'
        Headers = $queryParams.Headers
        Body = @{
            dnsEntry = @{
                name    = $RelativeName
                type    = 'TXT'
                content = $TxtValue
                expire  = $ttl
            }
        } | ConvertTo-Json
        ContentType = 'application/json'
        Verbose = $false
        ErrorAction = 'Stop'
    }
    try {
        Write-Debug "POST $($queryParams.Uri)`n$($queryParams.Body)"
        $null = Invoke-RestMethod @queryParams @script:UseBasic
    } catch { throw }

    <#
    .SYNOPSIS
        Adds a TXT record via the TransIP API.
    .DESCRIPTION
        Authenticates (private key/JWT), finds the correct domain, and adds a TXT if missing.
    .PARAMETER RecordName
        The full DNS record (typically _acme-challenge.domain.com).
    .PARAMETER TxtValue
        TXT value to create.
    .PARAMETER TIPUsername
        TransIP account login.
    .PARAMETER TIPKeyText
        (Preferred) PEM private key as SecureString.
    .PARAMETER TIPKeyPath
        PEM key file path.
    .PARAMETER TIPEnforceWhitelist
        When using a key that does not require IP whitelisting, using this switch enables IP whitelisting enforcement.
    .PARAMETER TIPAccessToken
        A pre-authenticated JWT access token.
    .PARAMETER TIPAPIEndpoint
        (Optional) Override for TransIP API.
    .EXAMPLE
        Add-DnsTxt -RecordName '_acme-challenge.example.com' -TxtValue 'val' -TIPUsername 'transipuser' -TIPKeyText $sec -TIPEnforceWhitelist
    .EXAMPLE
        Add-DnsTxt -RecordName '_acme-challenge.example.com' -TxtValue 'val' -TIPAccessToken $token
    #>

}

function Remove-DnsTxt {
    [CmdletBinding(DefaultParameterSetName = 'KeyAuthWithSecureString')]
    param (
        # Standard DNS and config
        [Parameter(Mandatory, Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory, Position=1)]
        [string]$TxtValue,

        # Shared between KeyAuth sets
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPUsername,

        # Private Key as a SecureString
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [securestring]$TIPKeyText,
        # Private Key defined as a Filepath to a file
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPKeyPath,

        # Whether or not to enforce IP whitelisting for the key
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString')]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath')]
        [switch]$TIPEnforceWhitelist,

        # Supplying your own JWT auth token instead is also possible
        [Parameter(ParameterSetName = 'TokenAuth', Mandatory)]
        [string]$TIPAccessToken,

        [string]$TIPAPIEndpoint = "https://api.transip.nl/v6"
    )
    # Decide token source: supplied or generated
    if ($PSCmdlet.ParameterSetName -eq 'TokenAuth') {
        # Use supplied TIPAccessToken
        $token = $TIPAccessToken
    } else {
        # Generate JWT using the given keys
        $token = Get-TransIPJwtToken -TIPUsername $TIPUsername -TIPKeyText $TIPKeyText -TIPKeyPath $TIPKeyPath -TIPEnforceWhitelist:$TIPEnforceWhitelist:IsPresent -TIPAPIEndpoint $TIPAPIEndpoint
    }
    # Identify root domain as before
    $RootDomain = Find-TransIPRootDomain -RecordName $RecordName -Token $token -TIPAPIEndpoint $TIPAPIEndpoint
    if (-not $RootDomain) { throw "Could not determine root domain for $RecordName" }
    # Relative name for deletion
    $RelativeName = Get-TransIPRelativeName -RecordName $RecordName -RootDomain $RootDomain
    $queryParams = @{
        Uri = "$TIPAPIEndpoint/domains/$RootDomain/dns"
        Headers = @{ Authorization = "Bearer $token" }
        Verbose = $false
        ErrorAction = 'Stop'
    }
    try {
        Write-Debug "GET $($queryParams.Uri)"
        $records = (Invoke-RestMethod @queryParams @script:UseBasic).dnsEntries
    } catch { throw }

    # check for matching record to delete
    $ttl = 300
    $recMatch = @($records | Where-Object {
        $_.name -eq $RelativeName -and $_.type -eq "TXT" -and $_.content -eq $TxtValue
    })
    if (-not ($recMatch | Where-Object { $_.content -eq $TxtValue })) {
        Write-Verbose "TXT record '$RelativeName' with value '$TxtValue' does not exist at $RootDomain. No action needed."
        return
    }
    if ($recMatch) {
        $ttl = $recMatch[0].expire
        Write-Debug "Using TTL $ttl from existing record"
    }

    # Build and submit delete call for DNS TXT entry
    $queryParams = @{
        Uri = "$TIPAPIEndpoint/domains/$RootDomain/dns/$RelativeName"
        Method = 'DELETE'
        Headers = $queryParams.Headers
        Body = @{
            dnsEntry = @{
                name    = $RelativeName
                type    = 'TXT'
                content = $TxtValue
                expire  = $ttl
            }
        } | ConvertTo-Json
        ContentType = 'application/json'
        Verbose = $false
        ErrorAction = 'Stop'
    }
    try {
        Write-Debug "DELETE $($queryParams.Uri)`n$($queryParams.Body)"
        $null = Invoke-RestMethod @queryParams @script:UseBasic
    } catch { throw }

    <#
    .SYNOPSIS
        Removes a TXT record via the TransIP API.
    .DESCRIPTION
        Authenticates (private key/JWT), finds the correct domain, and deletes a TXT if present.
    .PARAMETER RecordName
        The full DNS record (typically _acme-challenge.domain.com).
    .PARAMETER TxtValue
        TXT value to delete.
    .PARAMETER TIPUsername
        TransIP account login.
    .PARAMETER TIPKeyText
        (Preferred) PEM private key as SecureString.
    .PARAMETER TIPKeyPath
        PEM key file path.
    .PARAMETER TIPEnforceWhitelist
        When using a key that does not require IP whitelisting, using this switch enables IP whitelisting enforcement.
    .PARAMETER TIPAccessToken
        A pre-authenticated JWT access token.
    .PARAMETER TIPAPIEndpoint
        (Optional) Override for TransIP API.
    .EXAMPLE
        Remove-DnsTxt -RecordName '_acme-challenge.example.com' -TxtValue 'val' -TIPUsername 'transipuser' -TIPKeyText $sec -TIPEnforceWhitelist
    .EXAMPLE
        Remove-DnsTxt -RecordName '_acme-challenge.example.com' -TxtValue 'val' -TIPAccessToken $token
    #>

}

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://api.transip.nl/rest/docs.html


function Get-TransIPPrivateKey {
    param (
        [securestring]$TIPKeyText,
        [string]$TIPKeyPath
    )
    $pem = $null
    try {
        # Use SecureString if supplied
        if ($TIPKeyText) {
            # Marshal SecureString to BSTR pointer (Windows internal string)
            $unmanaged = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($TIPKeyText)
            try {
                # Convert pointer to managed .NET string (plaintext in memory only briefly)
                $pem = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($unmanaged)
            } finally {
                # Wipe BSTR memory after use for security
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($unmanaged)
            }
        } elseif ($TIPKeyPath) {
            # Load from supplied file path (should be protected by NTFS permissions)
            if (-not (Test-Path $TIPKeyPath)) {
                throw "Private key file not found: $TIPKeyPath"
            }
            $pem = Get-Content -Raw -Path $TIPKeyPath
        } else {
            # Neither key method supplied
            throw "No private key provided. Supply -TIPKeyText or -TIPKeyPath."
        }
        # Validate PEM string exists
        if (-not $pem) { throw "Failed to load PEM private key content." }
        return $pem
    } finally {
        # Sensitive variables are cleared by calling code
    }
    <#
    .SYNOPSIS
        Securely loads the RSA private key as a PEM string.
    .DESCRIPTION
        Loads the private key from either a SecureString (preferred) or a file path.
        Sensitive variables are cleared ASAP.
    .PARAMETER TIPKeyText
        Private key as SecureString.
    .PARAMETER TIPKeyPath
        File path to PEM key.
    .OUTPUTS
        PEM-formatted private key string.
    #>

}

function Import-RsaPrivateKey {
    param (
        [Parameter(Mandatory)]
        [string]$PemString
    )
    # For PowerShell 7+, use the native ImportFromPem method
    if ($PSVersionTable.PSVersion.Major -ge 7) {
        Write-Debug "Importing key via .NET"
        $rsa = [System.Security.Cryptography.RSA]::Create()
        $rsa.ImportFromPem($PemString)
        return $rsa
    }
    Write-Debug "Parsing PEM key manually"
    # On PS5.1 — support both PKCS#1 and PKCS#8
    $clean = $PemString -replace "\r","" -replace "\n",""
    if ($clean -match '-----BEGIN RSA PRIVATE KEY-----') {
        # PKCS#1 (classic OpenSSL)
        $base64 = ($PemString -split "\r?\n" | Where-Object {
            $_ -notmatch "^-+.*PRIVATE.*-+$"
        }) -join ""
        $keyBytes = [Convert]::FromBase64String($base64)
        $reader = [IO.BinaryReader]::new([IO.MemoryStream]::new($keyBytes))
        $twoBytes = $reader.ReadUInt16()
        if ($twoBytes -ne 0x8130 -and $twoBytes -ne 0x8230) {
            throw "PEM parse error (ASN.1)"
        }
        $reader.BaseStream.Seek(15, 'Current')
        function ReadInt {
            $size = $reader.ReadByte()
            if ($size -eq 0x81) { $size = $reader.ReadByte() }
            [byte[]]$bytes = $reader.ReadBytes($size)
            if ($bytes[0] -eq 0x00) { $bytes = $bytes[1..($bytes.Length-1)] }
            return $bytes
        }
        $modulus = ReadInt
        $exponent = ReadInt
        $d = ReadInt
        $p = ReadInt
        $q = ReadInt
        $dp = ReadInt
        $dq = ReadInt
        $iq = ReadInt
        $rsaParams = [Security.Cryptography.RSAParameters]::new()
        $rsaParams.Modulus = $modulus
        $rsaParams.Exponent = $exponent
        $rsaParams.D = $d
        $rsaParams.P = $p
        $rsaParams.Q = $q
        $rsaParams.DP = $dp
        $rsaParams.DQ = $dq
        $rsaParams.InverseQ = $iq
        $rsaProv = [Security.Cryptography.RSACryptoServiceProvider]::new()
        $rsaProv.PersistKeyInCsp = $false
        $rsaProv.ImportParameters($rsaParams)
        return $rsaProv
    } elseif ($clean -match '-----BEGIN PRIVATE KEY-----') {
        # PKCS#8 (modern OpenSSL export)
        $base64 = ($PemString -split "\r?\n" | Where-Object {
            $_ -notmatch "^-+.*PRIVATE KEY-+$"
        }) -join ""
        $pkcs8bytes = [Convert]::FromBase64String($base64)
        try {
            $cngKey = [Security.Cryptography.CngKey]::Import(
                $pkcs8bytes,
                [Security.Cryptography.CngKeyBlobFormat]::Pkcs8PrivateBlob
            )
            $rsaCng = New-Object System.Security.Cryptography.RSACng($cngKey)
            return $rsaCng
        } catch {
            throw "Could not import PKCS#8 key in Windows PowerShell 5.1. Only supported in updated environments. Error details: $_"
        }
    } else {
        throw "Unrecognized private key PEM format. Only PKCS#1 (BEGIN RSA PRIVATE KEY) and PKCS#8 (BEGIN PRIVATE KEY) are supported."
    }
    <#
    .SYNOPSIS
        Imports a PEM-encoded RSA private key.
    .DESCRIPTION
        Supports PS7+ natively and emulates PKCS#1 parsing for PS5.1.
    .PARAMETER PemString
        PEM private key string.
    .OUTPUTS
        [System.Security.Cryptography.RSA] (PS7+) or [System.Security.Cryptography.RSACryptoServiceProvider] (PS5.1)
    #>

}

function Get-TransIPJwtToken {
    param (
        [Parameter(Mandatory)]
        [string]$TIPUsername,
        [switch]$TIPEnforceWhitelist,
        [securestring]$TIPKeyText,
        [string]$TIPKeyPath,
        [string]$TIPAPIEndpoint = "https://api.transip.nl/v6"
    )
    # Retrieve private key contents
    $PrivateKey = Get-TransIPPrivateKey -TIPKeyText $TIPKeyText -TIPKeyPath $TIPKeyPath
    $rsa = Import-RsaPrivateKey -PemString $PrivateKey

    try {
        # Build a random nonce and JWT label for security and logging
        $nonce = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 16 | ForEach-Object {[char]$_})
        $randomNum = Get-Random -Minimum 10000 -Maximum 99999
        $tokenBody = @{
            login           = $TIPUsername
            nonce           = $nonce
            global_key      = (-not $TIPEnforceWhitelist.IsPresent)
            expiration_time = "5 minutes"
            label           = "Posh-ACME-$randomNum"
        } | ConvertTo-Json -Compress
        # Sign the body using SHA512 PKCS#1
        $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($tokenBody)
        $sigRaw = $rsa.SignData(
            $bodyBytes,
            [System.Security.Cryptography.HashAlgorithmName]::SHA512,
            [System.Security.Cryptography.RSASignaturePadding]::Pkcs1
        )
        $sigB64 = [Convert]::ToBase64String($sigRaw)
        # Call TransIP /auth endpoint for JWT
        $queryParams = @{
            Uri = "$TIPAPIEndpoint/auth"
            Method = 'POST'
            Body = $tokenBody
            Headers = @{ Signature = $sigB64 }
            ContentType = 'application/json'
            Verbose = $false
            ErrorAction = 'Stop'
        }
        Write-Debug "POST $($queryParams.Uri)`n$($tokenBody)"
        $response = Invoke-RestMethod @queryParams @script:UseBasic
        return $response.token
    } catch {
        throw
    } finally {
        # Clear private key material as soon as possible
        $PrivateKey = $null
        if ($rsa -is [System.Security.Cryptography.RSACryptoServiceProvider]) {
            $rsa.Clear()
        }
    }
    <#
    .SYNOPSIS
        Retrieves a JWT token for authenticating with the TransIP API.
    .DESCRIPTION
        Authenticates with the TransIP API and returns a short-lived JWT for subsequent calls.
    .PARAMETER TIPUsername
        Your TransIP username/login.
    .PARAMETER TIPKeyText
        (Preferred) Private key as SecureString.
    .PARAMETER TIPKeyPath
        PEM key file path.
    .PARAMETER TIPEnforceWhitelist
        When using a key that does not require IP whitelisting, using this switch enables IP whitelisting enforcement.
    .PARAMETER TIPAPIEndpoint
        Optional override for the TransIP API endpoint.
    .RETURNS
        JWT token string.
    .EXAMPLE
        $token = Get-TransIPJwtToken -TIPUsername 'transipuser' -TIPKeyText $sec -TIPEnforceWhitelist
    #>

}

function Find-TransIPRootDomain {
    param (
        [Parameter(Mandatory)]
        [string]$RecordName,
        [Parameter(Mandatory)]
        [string]$Token,
        [string]$TIPAPIEndpoint = "https://api.transip.nl/v6"
    )
    $queryParams = @{
        Uri = "$TIPAPIEndpoint/domains"
        Headers = @{ Authorization = "Bearer $Token" }
        Verbose = $false
        ErrorAction = 'Stop'
    }
    # Get all domains under the account
    try {
        Write-Debug "GET $($queryParams.Uri)"
        $domainList = (Invoke-RestMethod @queryParams @script:UseBasic).domains
    } catch { throw }
    $found = $null
    $longest = 0
    # Look for the most specific (longest) root domain that is part of the record
    foreach ($d in $domainList) {
        $domainName = $d.name
        if ($RecordName -eq $domainName -or $RecordName -like "*.$domainName") {
            if ($domainName.Length -gt $longest) {
                $found = $domainName
                $longest = $domainName.Length
            }
        }
    }
    return $found
    <#
    .SYNOPSIS
        Finds the root domain for a given DNS record in your TransIP account.
    .DESCRIPTION
        Given a full DNS record name, queries your TransIP domains and matches to the longest (most specific) managed domain.
    .PARAMETER RecordName
        Full (sub)domain to locate.
    .PARAMETER Token
        TransIP API JWT.
    .PARAMETER TIPAPIEndpoint
        Optional override for endpoint.
    .RETURNS
        Domain name or $null.
    .EXAMPLE
        $root = Find-TransIPRootDomain -RecordName '_acme-challenge.example.com' -Token $token
    #>

}

function Get-TransIPRelativeName {
    param (
        [Parameter(Mandatory)]
        [string]$RecordName,
        [Parameter(Mandatory)]
        [string]$RootDomain
    )
    # If record == root, return apex/at sign
    if ($RecordName -eq $RootDomain) { return '@' }
    $ending = ".$RootDomain"
    # Otherwise, remove the root domain from the record name
    if ($RecordName -like "*$ending") {
        return $RecordName.Substring(0, $RecordName.Length - $ending.Length)
    } else {
        return $RecordName
    }
    <#
    .SYNOPSIS
        Computes the relative DNS record name for a DNS entry, for use with TransIP API.
    .DESCRIPTION
        Converts a full record name + root into the correct relative (sub)domain for TransIP API or @ for the root.
    .PARAMETER RecordName
        Full DNS record name.
    .PARAMETER RootDomain
        Matched root domain.
    .RETURNS
        Relative record or @.
    .EXAMPLE
        Get-TransIPRelativeName -RecordName _acme-challenge.example.com -RootDomain example.com
    #>

}

function Get-DnsTxt {
    [CmdletBinding(DefaultParameterSetName = 'KeyAuthWithSecureString')]
    param (
        # Standard DNS and config
        [Parameter(Mandatory, Position=0)]
        [string]$RecordName,

        # Shared between KeyAuth sets
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPUsername,

        # Private Key as a SecureString
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString', Mandatory)]
        [securestring]$TIPKeyText,
        # Private Key defined as a Filepath to a file
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath', Mandatory)]
        [string]$TIPKeyPath,

        # Whether or not to enforce IP whitelisting for the key
        [Parameter(ParameterSetName = 'KeyAuthWithSecureString')]
        [Parameter(ParameterSetName = 'KeyAuthWithFilePath')]
        [switch]$TIPEnforceWhitelist,

        # Supplying your own JWT auth token instead is also possible
        [Parameter(ParameterSetName = 'TokenAuth', Mandatory)]
        [string]$TIPAccessToken,

        [Parameter()][string]$TIPAPIEndpoint = "https://api.transip.nl/v6"
    )
    # Decide token source: supplied or generated
    if ($PSCmdlet.ParameterSetName -eq 'TokenAuth') {
        # Use TIPAccessToken directly
        $token = $TIPAccessToken
    } else {
        # Otherwise generate using private key
        $token = Get-TransIPJwtToken -TIPUsername $TIPUsername -TIPKeyText $TIPKeyText -TIPKeyPath $TIPKeyPath -TIPEnforceWhitelist:$TIPEnforceWhitelist.IsPresent -TIPAPIEndpoint $TIPAPIEndpoint
    }
    # Find appropriate root domain
    $RootDomain = Find-TransIPRootDomain -RecordName $RecordName -Token $token -TIPAPIEndpoint $TIPAPIEndpoint
    if (-not $RootDomain) { throw "Could not determine root domain for $RecordName" }
    # Convert to relative/sub domain
    $RelativeName = Get-TransIPRelativeName -RecordName $RecordName -RootDomain $RootDomain
    # Query for all DNS records for this domain
    $queryParams = @{
        Uri = "$TIPAPIEndpoint/domains/$RootDomain/dns"
        Headers = @{ Authorization = "Bearer $token" }
        Verbose = $false
        ErrorAction = 'Stop'
    }
    try {
        Write-Debug "GET $($queryParams.Uri)"
        $records = (Invoke-RestMethod @queryParams @script:UseBasic).dnsEntries
    } catch { throw }
    # Filter TXT records for the requested relative name
    $txtRecords = $records | Where-Object { $_.name -eq $RelativeName -and $_.type -eq "TXT" }
    # Return all TXT values as array
    return ($txtRecords.content)
    <#
    .SYNOPSIS
        Retrieves TXT DNS records for a given entry.
    .DESCRIPTION
        Authenticates (private key/JWT), finds the root domain, and lists all TXT.
    .PARAMETER TIPUsername
        TransIP account login.
    .PARAMETER TIPKeyText
        (Preferred) PEM private key as SecureString.
    .PARAMETER TIPKeyPath
        PEM key file path.
    .PARAMETER TIPEnforceWhitelist
        When using a key that does not require IP whitelisting, using this switch enables IP whitelisting enforcement.
    .PARAMETER TIPAccessToken
        A pre-authenticated JWT access token.
    .PARAMETER RecordName
        The full DNS record (typically _acme-challenge.domain.com).
    .PARAMETER TIPAPIEndpoint
        (Optional) Override for TransIP API.
    .RETURNS
        Array of TXT values.
    .EXAMPLE
        Get-DnsTxt -RecordName '_acme-challenge.example.com' -TIPUsername 'transipuser' -TIPKeyText $sec -TIPEnforceWhitelist
    .EXAMPLE
        Get-DnsTxt -RecordName '_acme-challenge.example.com' -TIPAccessToken $token
    #>

}