Step by step LetsEncrypt WinSimple: WILDCARD Edition

This section contains user-submitted tutorials.
Post Reply
palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-09-20 21:30

I've been looking for a way to create and renew letsencrypt wildcard certificates programatically. I finally stumbled across a script for the rest API for dynu.com, my dns provider. I found the script here: https://github.com/rmbolger/Posh-ACME/t ... DnsPlugins

I haven't tried Posh-ACME because I know and like Win-Acme. However, I found that posh-acme has many powershell scripts for different dns providers and they all have to follow a certain format. All of the scripts contain only functions, which is perfect for easy modification. These scripts are awesome because they already work for many dns provider APIs. This is also a great way to get certificates without having to need a web server for authentication. You only need to add a couple of minor things to make it work with Win-Acme, which sends the following variables to scripts:

{Task} {Identifier} {RecordName} {Token}

{Task} tells your script to CREATE or DELETE the validation dns text entry
{Identifier} is the domain name you're creating the certificate for
{RecordName} is the text record you're creating (_acme-challenge.example.com)
{Token} is the entry for the recordname, which is a unique string used for validation

Modify the script:

Find your dns provider in the list and edit it to add the following.

At the top of the file:

Code: Select all

param(
	[string]$Task,
	[string]$DomainName,
	[string]$RecordName,
	[string]$TxtValue
)

$DynuClientID = 'blah-blah-blah-blah-f8hdkg63jndh'
$DynuSecret = 'supersecretstringofbafflegarble'
I looked at a couple of scripts. At the bottom of each function is a description of the credentials you need for that provider. Dynu only requires two things, but, but for example, ClouDNS requires 5 items for credentialing: CDUserType, CDUsername, CDPassword, CDPasswordInsecure, CDPollPropagation. Get all of that information from your account at ClouDNS and turn them into variables as I did with the dynu credential info.

At the bottom of the file:

Code: Select all

if ($Task -eq 'create'){
	Add-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}

if ($Task -eq 'delete'){
	Remove-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}
This will run CREATE or DELETE based on the parameter sent from Win-Acme. Pay attention to the function names. For Dynu the two functions are Add-DnsTxtDynu and Remove-DnsTxtDynu. For ClouDNS, its Add-DnsTxtClouDNS and Remove-DnsTxtClouDNS.

Additionally, I had to comment out a few instances of the following in the script: @script:UseBasic

Don't ask me why, but it was causing authentication to fail.

To test the script, open powershell and run the script with the 4 parameters discussed above ({Task} {Identifier} {RecordName} {Token}).

Code: Select all

C:\scripts\lews\wacs\Scripts\Dynu.ps1 create example.com blah.example.com some-message-without-spaces
If successful, you should have text record blah.example.com with entry "some-message-without-spaces". Look on your dns provider's admin console to make sure.

Now, download the latest stable Win-Acme: https://github.com/PKISharp/win-acme

Unzip it somewhere, open a command prompt, CD to the win-acme directory and run wacs.exe. A new window will open. The console output below is using v2.0.10.444 of Win-Acme, which (as of today) is the latest stable version.

Code: Select all

 [INFO] A simple Windows ACMEv2 client (WACS)
 [INFO] Software version 2.0.10.444 (RELEASE)
 [INFO] IIS not detected
 [INFO] Scheduled task looks healthy
 [INFO] Please report issues at https://github.com/PKISharp/win-acme

 M: Create new certificate (full options)
 L: List scheduled renewals
 R: Renew scheduled
 S: Renew specific
 A: Renew *all*
 O: More options...
 Q: Quit

 Please choose from the menu: 
Choose M to create new certificate.

Code: Select all

 [INFO] Running in mode: Interactive, Advanced

  Please specify how the list of domain names that will be included in the
  certificate should be determined. If you choose for one of the "all bindings"
  options, the list will automatically be updated for future renewals to reflect
  the bindings at that time.

 1: Manual input
 2: Read a CSR created by another program
 <Enter>: Abort

 How shall we determine the domain(s) to include in the certificate?: 
Choose 1 to manual input domain names.

Code: Select all

 Enter comma-separated list of host names, starting with the common name: 
Enter the list separated by commas (NO SPACES!!!!). Example: example.com,*.example.com,poopoo.com,*.poopoo.com

You MUST put the wildcard as an alternative name, meaning if you don't, you will not have a certificate for the domain name - it will only work on subdomains. Also, the certificate will be known by the first entry.

Code: Select all

 [INFO] Target generated using plugin Manual: example.com and 1 alternatives

 Suggested FriendlyName is '[Manual] example.com', press enter to accept or type an alternative: 

Enter anything you want, such as example.com or just hit enter.

Code: Select all

  The ACME server will need to verify that you are the owner of the domain names
  that you are requesting the certificate for. This happens both during initial
  setup *and* for every future renewal. There are two main methods of doing so:
  answering specific http requests (http-01) or create specific dns records
  (dns-01). For wildcard domains the latter is the only option. Various
  additional plugins are available from https://github.com/PKISharp/win-acme/.

 1: [dns-01] Create verification records manually (auto-renew not possible)
 2: [dns-01] Create verification records with acme-dns (https://github.com/joohoi/acme-dns)
 3: [dns-01] Create verification records with your own script
 <Enter>: Abort

 How would you like prove ownership for the domain(s) in the certificate?: 
Enter 3 for using your own script.

Code: Select all

 Path to script that creates DNS records: 
Enter the full path.

Code: Select all

 1: Using the same script
 2: Using a different script
 3: Do not delete

 How to delete records after validation: 
Enter 1 to use the same script.

Code: Select all

 {Identifier}:        Domain that's being validated
 {RecordName}:        Full TXT record name
 {Token}:             Expected value in the TXT record

 Input parameters for create script, or enter for default "create {Identifier} {RecordName} {Token}": 
Just hit enter for the default values.

Code: Select all

 Input parameters for delete script, or enter for default "delete {Identifier} {RecordName} {Token}":
Just hit enter again for the default values.

Code: Select all

  After ownership of the domain(s) has been proven, we will create a Certificate
  Signing Request (CSR) to obtain the actual certificate. The CSR determines
  properties of the certificate like which (type of) key to use. If you are not
  sure what to pick here, RSA is the safe default.

 1: Elliptic Curve key
 2: RSA key

 What kind of private key should be used for the certificate?: 
I used RSA. You may have a reason to use elliptical.

Code: Select all

  When we have the certificate, you can store in one or more ways to make it
  accessible to your applications. The Windows Certificate Store is the default
  location for IIS (unless you are managing a cluster of them).

 1: IIS Central Certificate Store (.pfx per domain)
 2: PEM encoded files (Apache, nginx, etc.)
 3: Windows Certificate Store
 C: Abort

 How would you like to store the certificate?: 
Enter 2 for PEM. This will work without fuss with hMailServer.

Code: Select all

 Path to folder where .pem files are stored:
Choose a path and enter it.

Code: Select all

 1: IIS Central Certificate Store (.pfx per domain)
 2: Windows Certificate Store
 3: No additional storage steps required
 C: Abort

 Would you like to store it in another way too?: 
Enter 3 for no additional, unless you want one of the other options.

Code: Select all

  With the certificate now saved to the store(s) of your choice, you may choose
  one or more steps to update your applications, e.g. to configure the new
  thumbprint, or to update bindings.

 1: Start external script or program
 2: Do not run any (extra) installation steps

 Which installation step should run first?: 
Enter 2 for NO EXTRA installation steps.

Code: Select all

 [INFO] Authorize identifier: example.com
 [INFO] Authorizing example.com using dns-01 validation (DnsScript)
 [INFO] Script C:\scripts\lews\wacs\Scripts\Dynu.ps1 starting with parameters create example.com _acme-challenge.example.com ltWLguTpuTOlWnvCfscQekM5G8J1M74CgX5NYxxBCqU
 [INFO] Script finished
 [INFO] Answer should now be available at _acme-challenge.example.com
 [EROR] Preliminary validation failed

		*** I cut out some of the log - I had a "pre-validation" error related to the way win-acme deals with DDNS subdomains. 
		*** PreValidation is win-acme only and will not affect ACTUAL letsencrypt validation.

 [INFO] It looks like validation is going to fail, but we will try now anyway...
 [WARN] First chance error calling into ACME server, retrying with new nonce...
 [INFO] Authorization result: valid
 [INFO] Script C:\scripts\lews\wacs\Scripts\Dynu.ps1 starting with parameters delete example.com _acme-challenge.example.com ltWLguTpuTOlWnvCfscQekM5G8J1M74CgX5NYxxBCqU
 [INFO] Script finished
 [INFO] Requesting certificate example.com
 [INFO] Store with PemFiles...
 [INFO] Exporting .pem files to C:\xampp\certificates
 [INFO] Installing with None...
 [INFO] Scheduled task looks healthy

 Do you want to replace the existing task? (y/n*)  - 
If its your first successful run, it will ask to create a scheduled task. Answer yes. After that it doesn't really matter because the task will look to renew all certificates on each run.

Code: Select all

 [INFO] Adding renewal for example.com
 [INFO] Next renewal scheduled at 2019/11/14 10:35:58

 M: Create new certificate (full options)
 L: List scheduled renewals
 R: Renew scheduled
 S: Renew specific
 A: Renew *all*
 O: More options...
 Q: Quit

 Please choose from the menu:
Hit Q to quit. That's it. New wildcard certificate with automatic renewal. :D

palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-09-20 23:52

Restart apache Powershell script. Run daily via task scheduler. Works with multiple certificates. Looks for the newest one in the certificate store folder and then restarts apache service if within 1 day old. Change $ServiceName to whatever is shown in the Services console > properties to restart the service of your choice - even 'hMailServer'. :mrgreen:

Code: Select all

$ErrorActionPreference = 'silentlycontinue'

$ServiceName = 'Apache2.4'
$CommonSvcName = 'Apache'
$CertLocation = 'C:\xampp\certificates'

Function SendNotification($Body) {
	$EmailFrom = "notifier-account@gmail.com"
	$EmailTo = "1234567890@tmomail.net" 
	$SMTPServer = "smtp.gmail.com" 
	$SMTPAuthUser = "notifier-account@gmail.com"
	$SMTPAuthPass = "supersecretpassword"
	$Subject = "Windows Service Notification" 
	$SMTPClient = New-Object Net.Mail.SmtpClient($SmtpServer, 587) 
	$SMTPClient.EnableSsl = $true 
	$SMTPClient.Credentials = New-Object System.Net.NetworkCredential($SMTPAuthUser, $SMTPAuthPass); 
	$SMTPClient.Send($EmailFrom, $EmailTo, $Subject, $Body)
}

$NewestCert = Get-ChildItem -Path $CertLocation | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$LastModifiedDate = (Get-Item $CertLocation\$NewestCert).LastWriteTime
If ($LastModifiedDate -gt (Get-Date).AddDays(-1)){
	Restart-Service $ServiceName
	$Body = "$CommonSvcName service RESTARTING to load new SSL certificate." 
	SendNotification $Body
	Start-Sleep -seconds 60
	(Get-Service $ServiceName).Refresh()
	If ((Get-Service $ServiceName).Status -ne 'Running'){
		Start-Service $ServiceName
		$Body = "$CommonSvcName service RESTARTING due to a fault (new SSL certificate) - 2nd attempt." 
		SendNotification $Body
		Start-Sleep -seconds 60
		(Get-Service $ServiceName).Refresh()
		If ((Get-Service $ServiceName).Status -ne 'Running'){
			$Body = "$CommonSvcName service could not be restarted." 
			SendNotification $Body
		}
	}
}

palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-09-26 23:18

From the tutorial:

Code: Select all

EROR] Preliminary validation failed

		*** I cut out some of the log - I had a "pre-validation" error related to the way win-acme deals with DDNS subdomains. 
		*** PreValidation is win-acme only and will not affect ACTUAL letsencrypt validation.

 [INFO] It looks like validation is going to fail, but we will try now anyway...
 [WARN] First chance error calling into ACME server, retrying with new nonce...
 [INFO] Authorization result: valid
The developer has confirmed that prevalidation for ddns subdomains is a bug. This does not affect actual validation whatsoever. It just adds a few minutes of wait time while going through prevalidation failing.
WouterTinus
commented about 1 hour ago
This happened because dynu.net is considered to be a TLD by the Public Suffix List and we didn't consider TLD name servers in the prevalidation code, as they are not commonly authoritative for registrerable domains. Though obviously in some cases they are, so they should be considered. This will be fixed in the next release
More info here: https://github.com/PKISharp/win-acme/is ... -535660844

radiocooke
New user
New user
Posts: 3
Joined: 2019-11-30 19:35

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by radiocooke » 2019-11-30 20:36

Hello, I tried following this guide but am running into a few errors. I'll add that I am new to PowerShell so it's likely something I am overlooking.
my dynu.ps1 is at the bottom of my post. The only changes are the beginning and end changes in the instructions from palinka.

This is the error I get which appears to be related to the @script:UseBasic lines in the script.
Invoke-RestMethod : A positional parameter cannot be found that accepts argument '$null'.
At C:\LetsEncrypt\Scripts\Dynu.ps1:232 char:21
+ ... $response = Invoke-RestMethod -Uri "https://api.dynu.com/v2/oauth2/to ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidArgument: (:) [Invoke-RestMethod], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : PositionalParameterNotFound,Microsoft.PowerShell.Commands.InvokeRestMethodCommand
I found in another forum that adding $script:UseBasic = @{UseBasicParsing=$true} to the top of the script, or (as palinka instructed) removing those lines is a possible fix to the above error. Whether I add $script:UseBasic = @{UseBasicParsing=$true} or remove the $script:UseBasic entries, I get 2 different errors and that is where I'm stumped. here are those errors:
Get-DateTimeOffsetNow : The term 'Get-DateTimeOffsetNow' is not recognized as the name of a cmdlet, function, script file, or operable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
At C:\LetsEncrypt\Scripts\Dynu.ps1:245 char:19
+ Expiry = (Get-DateTimeOffsetNow).AddSeconds($response.expires ...
+ ~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-DateTimeOffsetNow:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException

Invoke-RestMethod : {"statusCode":401,"type":"Authentication Exception","message":"Failed."}
At C:\LetsEncrypt\Scripts\Dynu.ps1:40 char:21
+ ... $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?rec ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (Method: GET, Reques\u2026PowerShell/6.2.3
}:HttpRequestMessage) [Invoke-RestMethod], HttpResponseException
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand

Code: Select all

param(
	[string]$Task,
	[string]$DomainName,
	[string]$RecordName,
	[string]$TxtValue
)

$DynuClientID = '******************************'
$DynuSecret = '********************************'

function Add-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers @script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # nothing to do
        Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do."
    } else {
        # if there's at least one record in the response, we can get the zone ID from it, otherwise
        # we need to query for the zone ID in before we can add the new record
        if ($response.dnsRecords.Count -gt 0) {
            $zoneID = $response.dnsRecords[0].domainId
            $recNode = $response.dnsRecords[0].nodeName
        } else {
            # query the zone info
            try {
                $zoneResp = Invoke-RestMethod "$apiBase/dns/getroot/$RecordName" `
                    -Headers $headers @script:UseBasic
            } catch { throw }

            if ($zoneResp -and $zoneResp.id) {
                $zoneID = $zoneResp.id
                $recNode = $zoneResp.node
            } else {
                throw "No zone info returned for $RecordName from Dynu"
            }
        }

        # now that we have the zone ID, we can add the new record
        $bodyJson = @{
            nodeName = $recNode
            recordType = 'TXT'
            textData = $TxtValue
            state = $true
        } | ConvertTo-Json -Compress
        try {
            Write-Verbose "Adding a TXT record for $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$zoneID/record" -Method Post -Body $bodyJson `
                -Headers $headers -ContentType 'application/json' @script:UseBasic | Out-Null
        } catch { throw }
    }

    <#
    .SYNOPSIS
        Add a DNS TXT record to Dynu

    .DESCRIPTION
        Adds the TXT record to the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .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-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Adds a TXT record for the specified domain with the specified value.
    #>
}

function Remove-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers @script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # grab the record and delete it
        $rec = $response.dnsRecords | Where-Object { $_.textData -eq $TxtValue }
        try {
            Write-Verbose "Deleting $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$($rec.domainId)/record/$($rec.id)" -Method Delete `
                -Headers $headers @script:UseBasic | Out-Null
        } catch { throw }
    } else {
        # nothing to do
        Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do."
    }

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Dynu

    .DESCRIPTION
        Removes the TXT record from the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

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

    .EXAMPLE
        Remove-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Removes a TXT record for the specified domain with the specified value.
    #>
}

function Save-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )
    <#
    .SYNOPSIS
        Not required

    .DESCRIPTION
        This provider does not require calling this function to save 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://www.dynu.com/en-US/Resources/API

function Get-DynuAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DynuClientID,
        [Parameter(Mandatory, Position = 1)]
        [string]$DynuSecret
    )

    if ($script:DynuAccessToken -and ($script:DynuAccessToken.Expiry -lt (Get-DateTimeOffsetNow))) {
        return $script:DynuAccessToken.AccessToken
    }

    # Dynu's web server requires the credentials to be passed on the first call, so we can't just
    # use the -Credential parameter because it only adds credentials after passing nothing and getting
    # an auth challenge response.
    $encodedCreds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${DynuClientID}:${DynuSecret}"))
    $headers = @{
        Accept = 'application/json'
        Authorization = "Basic $encodedCreds"
    }

    try {
        $response = Invoke-RestMethod -Uri "https://api.dynu.com/v2/oauth2/token" -Headers $headers @script:UseBasic
    } catch {
        throw
    }

    if (-not $response.access_token) {
        Write-Debug ($response | ConvertTo-Json)
        throw "Access token not found in OAuth2 response from Dynu"
    }

    $script:DynuAccessToken = @{
        AccessToken = $response.access_token
        Expiry = (Get-DateTimeOffsetNow).AddSeconds($response.expires_in - 300)
    }

    return $script:DynuAccessToken.AccessToken
}

if ($Task -eq 'create'){
	Add-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}

if ($Task -eq 'delete'){
	Remove-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}

palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-12-01 02:20

radiocooke wrote:
2019-11-30 20:36
Hello, I tried following this guide but am running into a few errors. I'll add that I am new to PowerShell so it's likely something I am overlooking.
my dynu.ps1 is at the bottom of my post. The only changes are the beginning and end changes in the instructions from palinka.

This is the error I get which appears to be related to the @script:UseBasic lines in the script.
Invoke-RestMethod : A positional parameter cannot be found that accepts argument '$null'.
What happens when you try to run from powershell console?

Code: Select all

C:\path\to\wacs\Scripts\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
Maybe a dumb question, but is dynu.com your domain provider?

radiocooke
New user
New user
Posts: 3
Joined: 2019-11-30 19:35

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by radiocooke » 2019-12-01 03:41

palinka wrote:
2019-12-01 02:20
radiocooke wrote:
2019-11-30 20:36
Hello, I tried following this guide but am running into a few errors. I'll add that I am new to PowerShell so it's likely something I am overlooking.
my dynu.ps1 is at the bottom of my post. The only changes are the beginning and end changes in the instructions from palinka.

This is the error I get which appears to be related to the @script:UseBasic lines in the script.
Invoke-RestMethod : A positional parameter cannot be found that accepts argument '$null'.
What happens when you try to run from powershell console?

Code: Select all

C:\path\to\wacs\Scripts\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
Maybe a dumb question, but is dynu.com your domain provider?

Dynu is my domain provider, its a paid account, and what you typed is exactly the format of the command I am using in an administrative instance of PowerShell, obviously using my path to Dynu.ps1 and my domain record info.

Correction: I am running the PS command directly form the folder where the Dynu.ps1 file is and the command format I am using is:
.\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
There was a space in the original path to the Dynu.ps1 file I was working with, which is why I was starting the command directly form the file location. As a test I moved the Dynu.ps1 file to a path with no spaces and ran it exactly as your example shows. I got the same errors. I assumed the path was not the issue, but I wanted to confirm.

palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-12-01 18:13

radiocooke wrote:
2019-12-01 03:41
palinka wrote:
2019-12-01 02:20
radiocooke wrote:
2019-11-30 20:36
Hello, I tried following this guide but am running into a few errors. I'll add that I am new to PowerShell so it's likely something I am overlooking.
my dynu.ps1 is at the bottom of my post. The only changes are the beginning and end changes in the instructions from palinka.

This is the error I get which appears to be related to the @script:UseBasic lines in the script.


What happens when you try to run from powershell console?

Code: Select all

C:\path\to\wacs\Scripts\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
Maybe a dumb question, but is dynu.com your domain provider?

Dynu is my domain provider, its a paid account, and what you typed is exactly the format of the command I am using in an administrative instance of PowerShell, obviously using my path to Dynu.ps1 and my domain record info.

Correction: I am running the PS command directly form the folder where the Dynu.ps1 file is and the command format I am using is:
.\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
There was a space in the original path to the Dynu.ps1 file I was working with, which is why I was starting the command directly form the file location. As a test I moved the Dynu.ps1 file to a path with no spaces and ran it exactly as your example shows. I got the same errors. I assumed the path was not the issue, but I wanted to confirm.
Here's my whole script:

Code: Select all

param(
	[string]$Task,
	[string]$DomainName,
	[string]$RecordName,
	[string]$TxtValue
)

$DynuClientID = '_________________________'
$DynuSecret = '_________________________'

function Add-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers #@script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # nothing to do
        Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do."
    } else {
        # if there's at least one record in the response, we can get the zone ID from it, otherwise
        # we need to query for the zone ID in before we can add the new record
        if ($response.dnsRecords.Count -gt 0) {
            $zoneID = $response.dnsRecords[0].domainId
            $recNode = $response.dnsRecords[0].nodeName
        } else {
            # query the zone info
            try {
                $zoneResp = Invoke-RestMethod "$apiBase/dns/getroot/$RecordName" `
                    -Headers $headers #@script:UseBasic
            } catch { throw }

            if ($zoneResp -and $zoneResp.id) {
                $zoneID = $zoneResp.id
                $recNode = $zoneResp.node
            } else {
                throw "No zone info returned for $RecordName from Dynu"
            }
        }

        # now that we have the zone ID, we can add the new record
        $bodyJson = @{
            nodeName = $recNode
            recordType = 'TXT'
            textData = $TxtValue
            state = $true
        } | ConvertTo-Json -Compress
        try {
            Write-Verbose "Adding a TXT record for $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$zoneID/record" -Method Post -Body $bodyJson `
                -Headers $headers -ContentType 'application/json' | Out-Null #@script:UseBasic | Out-Null
        } catch { throw }
    }

    <#
    .SYNOPSIS
        Add a DNS TXT record to Dynu

    .DESCRIPTION
        Adds the TXT record to the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .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-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Adds a TXT record for the specified domain with the specified value.
    #>
}

function Remove-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers #@script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # grab the record and delete it
        $rec = $response.dnsRecords | Where-Object { $_.textData -eq $TxtValue }
        try {
            Write-Verbose "Deleting $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$($rec.domainId)/record/$($rec.id)" -Method Delete `
                -Headers $headers | Out-Null #@script:UseBasic | Out-Null
        } catch { throw }
    } else {
        # nothing to do
        Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do."
    }

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Dynu

    .DESCRIPTION
        Removes the TXT record from the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

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

    .EXAMPLE
        Remove-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Removes a TXT record for the specified domain with the specified value.
    #>
}

function Save-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )
    <#
    .SYNOPSIS
        Not required

    .DESCRIPTION
        This provider does not require calling this function to save 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
############################

# This function only exists so that we can mock it in Pester tests
function Get-DateTimeOffsetNow {
    [CmdletBinding()]
    param()
    [System.DateTimeOffset]::Now
}

# API Docs
# https://www.dynu.com/en-US/Resources/API

function Get-DynuAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DynuClientID,
        [Parameter(Mandatory, Position = 1)]
        [string]$DynuSecret
    )

    if ($script:DynuAccessToken -and ($script:DynuAccessToken.Expiry -lt (Get-DateTimeOffsetNow))) {
        return $script:DynuAccessToken.AccessToken
    }

    # Dynu's web server requires the credentials to be passed on the first call, so we can't just
    # use the -Credential parameter because it only adds credentials after passing nothing and getting
    # an auth challenge response.
    $encodedCreds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${DynuClientID}:${DynuSecret}"))
    $headers = @{
        Accept = 'application/json'
        "Authorization" = "Basic ${encodedCreds}"
    }

    try {
        $response = Invoke-RestMethod -Uri "https://api.dynu.com/v2/oauth2/token" -Headers $headers #@script:UseBasic
    } catch {
        throw
    }

    if (-not $response.access_token) {
        Write-Debug ($response | ConvertTo-Json)
        throw "Access token not found in OAuth2 response from Dynu"
    }

    $script:DynuAccessToken = @{
        AccessToken = $response.access_token
        Expiry = (Get-DateTimeOffsetNow).AddSeconds($response.expires_in - 300)
    }

    return $script:DynuAccessToken.AccessToken
}


if ($Task -eq 'create'){
	Add-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}

if ($Task -eq 'delete'){
	Remove-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}


radiocooke
New user
New user
Posts: 3
Joined: 2019-11-30 19:35

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by radiocooke » 2019-12-01 22:14

palinka wrote:
2019-12-01 18:13
radiocooke wrote:
2019-12-01 03:41
palinka wrote:
2019-12-01 02:20


What happens when you try to run from powershell console?

Code: Select all

C:\path\to\wacs\Scripts\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
Maybe a dumb question, but is dynu.com your domain provider?

Dynu is my domain provider, its a paid account, and what you typed is exactly the format of the command I am using in an administrative instance of PowerShell, obviously using my path to Dynu.ps1 and my domain record info.

Correction: I am running the PS command directly form the folder where the Dynu.ps1 file is and the command format I am using is:
.\Dynu.ps1 create example.com DnsTextEntryPrefix.example.com some-test-message-without-spaces
There was a space in the original path to the Dynu.ps1 file I was working with, which is why I was starting the command directly form the file location. As a test I moved the Dynu.ps1 file to a path with no spaces and ran it exactly as your example shows. I got the same errors. I assumed the path was not the issue, but I wanted to confirm.
Here's my whole script:

Code: Select all

param(
	[string]$Task,
	[string]$DomainName,
	[string]$RecordName,
	[string]$TxtValue
)

$DynuClientID = '_________________________'
$DynuSecret = '_________________________'

function Add-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers #@script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # nothing to do
        Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do."
    } else {
        # if there's at least one record in the response, we can get the zone ID from it, otherwise
        # we need to query for the zone ID in before we can add the new record
        if ($response.dnsRecords.Count -gt 0) {
            $zoneID = $response.dnsRecords[0].domainId
            $recNode = $response.dnsRecords[0].nodeName
        } else {
            # query the zone info
            try {
                $zoneResp = Invoke-RestMethod "$apiBase/dns/getroot/$RecordName" `
                    -Headers $headers #@script:UseBasic
            } catch { throw }

            if ($zoneResp -and $zoneResp.id) {
                $zoneID = $zoneResp.id
                $recNode = $zoneResp.node
            } else {
                throw "No zone info returned for $RecordName from Dynu"
            }
        }

        # now that we have the zone ID, we can add the new record
        $bodyJson = @{
            nodeName = $recNode
            recordType = 'TXT'
            textData = $TxtValue
            state = $true
        } | ConvertTo-Json -Compress
        try {
            Write-Verbose "Adding a TXT record for $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$zoneID/record" -Method Post -Body $bodyJson `
                -Headers $headers -ContentType 'application/json' | Out-Null #@script:UseBasic | Out-Null
        } catch { throw }
    }

    <#
    .SYNOPSIS
        Add a DNS TXT record to Dynu

    .DESCRIPTION
        Adds the TXT record to the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .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-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Adds a TXT record for the specified domain with the specified value.
    #>
}

function Remove-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory,Position=2)]
        [string]$DynuClientID,
        [Parameter(Mandatory,Position=3)]
        [string]$DynuSecret,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    $apiBase = 'https://api.dynu.com/v2'
    $RecordName = $RecordName.ToLower()

    # authenticate
    $token = Get-DynuAccessToken $DynuClientID $DynuSecret
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    # The v2 API has a super convenient method for querying a record entirely based on hostname
    # so we don't have to deal with the typical find zone id rigamarole.
    try {
        $response = Invoke-RestMethod "$apiBase/dns/record/$($RecordName)?recordType=TXT" `
            -Headers $headers #@script:UseBasic
    } catch { throw }

    if ($response.dnsRecords -and $TxtValue -in $response.dnsRecords.textData) {
        # grab the record and delete it
        $rec = $response.dnsRecords | Where-Object { $_.textData -eq $TxtValue }
        try {
            Write-Verbose "Deleting $RecordName with value $TxtValue"
            Invoke-RestMethod "$apiBase/dns/$($rec.domainId)/record/$($rec.id)" -Method Delete `
                -Headers $headers | Out-Null #@script:UseBasic | Out-Null
        } catch { throw }
    } else {
        # nothing to do
        Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do."
    }

    <#
    .SYNOPSIS
        Remove a DNS TXT record from Dynu

    .DESCRIPTION
        Removes the TXT record from the Dynu zone

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

    .PARAMETER TxtValue
        The value of the TXT record.

    .PARAMETER DynuClientID
        The API Client ID for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

    .PARAMETER DynuSecret
        The API Secret for the Dynu account. Can be found at https://www.dynu.com/en-US/ControlPanel/APICredentials

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

    .EXAMPLE
        Remove-DnsTxtDynu '_acme-challenge.site1.example.com' 'asdfqwer12345678' 'dynu_api_client_id' 'dynu_api_client_secret'

        Removes a TXT record for the specified domain with the specified value.
    #>
}

function Save-DnsTxtDynu {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )
    <#
    .SYNOPSIS
        Not required

    .DESCRIPTION
        This provider does not require calling this function to save 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
############################

# This function only exists so that we can mock it in Pester tests
function Get-DateTimeOffsetNow {
    [CmdletBinding()]
    param()
    [System.DateTimeOffset]::Now
}

# API Docs
# https://www.dynu.com/en-US/Resources/API

function Get-DynuAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DynuClientID,
        [Parameter(Mandatory, Position = 1)]
        [string]$DynuSecret
    )

    if ($script:DynuAccessToken -and ($script:DynuAccessToken.Expiry -lt (Get-DateTimeOffsetNow))) {
        return $script:DynuAccessToken.AccessToken
    }

    # Dynu's web server requires the credentials to be passed on the first call, so we can't just
    # use the -Credential parameter because it only adds credentials after passing nothing and getting
    # an auth challenge response.
    $encodedCreds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${DynuClientID}:${DynuSecret}"))
    $headers = @{
        Accept = 'application/json'
        "Authorization" = "Basic ${encodedCreds}"
    }

    try {
        $response = Invoke-RestMethod -Uri "https://api.dynu.com/v2/oauth2/token" -Headers $headers #@script:UseBasic
    } catch {
        throw
    }

    if (-not $response.access_token) {
        Write-Debug ($response | ConvertTo-Json)
        throw "Access token not found in OAuth2 response from Dynu"
    }

    $script:DynuAccessToken = @{
        AccessToken = $response.access_token
        Expiry = (Get-DateTimeOffsetNow).AddSeconds($response.expires_in - 300)
    }

    return $script:DynuAccessToken.AccessToken
}


if ($Task -eq 'create'){
	Add-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}

if ($Task -eq 'delete'){
	Remove-DnsTxtDynu $RecordName $TxtValue $DynuClientID $DynuSecret
}


Thank you very much. That helped a lot. My script was missing a section that defined the Get-DateTimeOffsetNow function:

Code: Select all

function Get-DateTimeOffsetNow {
    [CmdletBinding()]
    param()
    [System.DateTimeOffset]::Now
}

This appeared to be the cause of the Get-DateTimeOffsetNow error. I went back and looked at the source file
and that line of code is not in it, so it does not appear to be something I deleted by mistake. After that I just needed to create new API keys which got rid of the 401 error and now it's working, thanks again for your help!

palinka
Senior user
Senior user
Posts: 1303
Joined: 2017-09-12 17:57

Re: Step by step LetsEncrypt WinSimple: WILDCARD Edition

Post by palinka » 2019-12-02 03:50

Awesome. Good job.

Post Reply