StatusCakeSSL.psm1

enum Ensure
{
    Absent
    Present
}

[DscResource()]
class StatusCakeSSL
{
    [DscProperty(Mandatory)]
    [Ensure] $Ensure

    [DscProperty(Key)]
    [string]$Name
    
    [DScProperty()]
    [PSCredential] $ApiCredential = [PSCredential]::Empty

    [DscProperty()]
    [PSCredential]$BasicCredential = [PSCredential]::Empty

    [DscProperty()]
    [int] $CheckRate = 300  

    [DscProperty()]
    [boolean] $AlertOnExpiration
    [DscProperty()]
    [boolean] $AlertOnProblems
    [DscProperty()]
    [boolean] $AlertOnReminders

    [DscProperty()]
    [int] $FirstReminderInDays = 30 

    [DscProperty()]
    [int] $SecondReminderInDays = 7  

    [DscProperty()]
    [int] $FinalReminderInDays = 1  

    [DScProperty()]
    [string[]] $ContactGroup
    [DscProperty()]
    [int] $MaxRetries = 5

    [DscProperty()]
    [bool] $paused =  $false

    [DscProperty(NotConfigurable)]
    [int] $id  

    [DscProperty(NotConfigurable)]
    [int[]] $ContactGroupID 

    
    [void] Set()
    {        
        $refObject = $this.Get()
        $testOK = $this.Test()
        $status = $null

        if($this.Ensure -eq [Ensure]::Absent -and $refObject.id -ne 0)
        {
            # we need to delete it"
            Write-Verbose ("Deleting Test " + $this.Name + " ID: " + $refObject.id)
            $status = $this.GetApiResponse(("/SSL/Update?id=$($refObject.id)"), 'DELETE', $null)
        }
        elseif($this.Ensure -eq [Ensure]::Present)
        {
            if($refObject.id -eq 0)
            {
                # we need to create it
                Write-Verbose ("Creating SSL Check " + $this.Name)
                $status = $this.GetApiResponse(('/SSL/Update'), "PUT", $this.GetObjectToPost(0, $this.ResolveContactGroups($this.contactGroup)))
            }
            else
            {
                # we need to update it
                Write-Verbose ("Updating SSL Check " + $this.Name)
                $status = $this.GetApiResponse(('/SSL/Update'), "PUT", $this.GetObjectToPost($refObject.id, $this.ResolveContactGroups($this.contactGroup)))
            }
                
        }

        if($null -ne $status)
        {
            Write-Verbose ("Status returned from API: " + ($status | ConvertTo-json -depth 4))
        }        
    }        
    
    [bool] Test()
    {        
        $testOK = $true # assume it's fine
        $refobject = $this.Get()

        if($this.Ensure -ne $refObject.Ensure)
        {
            Write-Verbose ("Ensure differs, expecting: '{0}' but seeing: '{1}'" -f $this.Ensure, $refObject.Ensure)
            $testOK = $false
        }


        if($this.Name -ne $refobject.Name)
        {
            Write-Verbose ("Domain Name has changed, expecting: '{0}' but seeing: '{1}'" -f $this.Name, $refobject.Name)
            $testOK = $false
        }

        if($this.CheckRate -ne $refobject.CheckRate)
        {
            Write-Verbose ("Check rate differs, expecting: '{0}' but seeing: '{1}'" -f $this.CheckRate, $refobject.CheckRate)
            $testOK = $false
        }

        if($this.ContactGroup -ne $refObject.ContactGroup)
        {
            $ExpectedContactGroups = $this.ContactGroup | ? { $_ }
            $ActualContactGroups = $refObject.ContactGroup | ? { $_ }
            Write-Verbose ("Contact Groups have Changed, expecting: '{0}' but seeing: '{1}'" -f $ExpectedContactGroups,  $ActualContactGroups)
            $testOK = $false
        }

        if($this.AlertOnExpiration -ne $refObject.AlertOnExpiration)
        {
            Write-Verbose ("AlertOnExpiration have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.AlertOnExpiration, $refObject.AlertOnExpiration)
            $testOK = $false
        }


        if($this.AlertOnProblems -ne $refObject.AlertOnProblems)
        {
            Write-Verbose ("AlertOnProblems have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.AlertOnProblems, $refObject.AlertOnProblems)
            $testOK = $false
        }

        if($this.AlertOnReminders -ne $refObject.AlertOnReminders)
        {
            Write-Verbose ("AlertOnReminders have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.AlertOnReminders, $refObject.AlertOnReminders)
            $testOK = $false
        }

        if($this.FirstReminderInDays -ne $refObject.FirstReminderInDays)
        {
            Write-Verbose ("FirstReminderInDays have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.FirstReminderInDays, $refObject.FirstReminderInDays)
            $testOK = $false
        }

        if($this.SecondReminderInDays -ne $refObject.SecondReminderInDays)
        {
            Write-Verbose ("SecondReminderInDays have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.SecondReminderInDays, $refObject.SecondReminderInDays)
            $testOK = $false
        }

        if($this.FinalReminderInDays -ne $refObject.FinalReminderInDays)
        {
            Write-Verbose ("FinalReminderInDays have Changed, expecting: '{0}' but seeing: '{1}'" -f $this.FinalReminderInDays, $refObject.FinalReminderInDays)
            $testOK = $false
        }
        return $testOK
    }

    [void] Validate()
    {
        write-verbose "Starting Validation" 
        if($this.CheckRate -ge 24000)
        {
            throw "Checkrate cannot be larger than 24000"
        }

        if($this.CheckRate -le 0)
        {
            throw "Checkrate cannot be zero or negative"
        }
        if($this.ContactGroup -ne $null)
        {
            $CheckContactGroupId = $this.ResolveContactGroups($this.contactGroup)    
            if ($($CheckContactGroupId.count) -eq 0){
                throw "You have specified a contact group that doesn't exist, cannot proceed."
            }
        }
        write-verbose "Finishing Validation" 
    }
  
    [StatusCakeSSL] Get()
    {        
        # first things first, validate
        $this.Validate();
        
        # does it exist?
        $checkId = $this.GetApiResponse("/SSL/", "GET", $null) | Where-Object {$_.domain -eq $this.Name} | Select-Object -expand id
        $returnobject = [StatusCakeSSL]::new()      

        # need a check here for duped by name
        if(($checkId | Measure-Object | Select -expand Count) -gt 1)
        {
            throw "Multiple Ids found with the same name. StatusCakeDSC uses Test Name as a unique key, and cannot continue"
        }

        if(($checkId | Measure-Object | Select -expand Count) -le 0)
        {
            Write-Verbose "Looks like our check doesn't exist"
            # check doesn't exist
            $returnObject.Ensure = [Ensure]::Absent
            $returnobject.Name = $this.Name #is property: domain
            #$returnobject.paused = $this.paused
            $returnobject.AlertOnExpiration = $this.AlertOnExpiration 
            $returnobject.AlertOnProblems = $this.AlertOnProblems 
            $returnobject.AlertOnReminders = $this.AlertOnReminders  
            $returnobject.FirstReminderInDays = $this.FirstReminderInDays  
            $returnobject.SecondReminderInDays = $this.SecondReminderInDays 
            $returnobject.FinalReminderInDays = $this.FinalReminderInDays 
            $returnobject.ContactGroup = $this.contactGroup
            $returnobject.id = 0 # null misbehaves
            #$this.TestID = 0
        }
        else
        {
            Write-Verbose "Check exists, fetching details from remote"
            $sslDetails = $this.GetApiResponse("/SSL/?id=$checkId", 'GET', $null)    
                                    
            $returnObject.Ensure = [Ensure]::Present
            $returnobject.Name = $this.Name
            #$returnobject.paused = $sslDetails.paused
            $returnobject.AlertOnExpiration = $sslDetails.alert_expiry 
            $returnobject.AlertOnProblems = $sslDetails.alert_broken 
            $returnobject.AlertOnReminders = $sslDetails.alert_reminder
            $returnobject.FirstReminderInDays = $sslDetails.alert_at.split(',')[2]
            $returnobject.SecondReminderInDays = $sslDetails.alert_at.split(',')[1]
            $returnobject.FinalReminderInDays = $sslDetails.alert_at.split(',')[0]
            $returnobject.ContactGroup = $this.ResolveContactGroupIdsToNames($sslDetails.contact_groups)
            $returnObject.id = $CheckID
            #$this.TestID = $CheckID
        }
        return $returnobject 
    }

    [Object] GetApiResponse($stem, $method, $body)
    {
        if($null -ne $body)
        {
            Write-Verbose ($body | convertto-json -depth 4)
        }

        $creds = @{}
        if($this.ApiCredential -eq [PSCredential]::Empty)
        {
            # no Api Key provided, grab 'em off the disk
            if(-not (Test-Path "$env:ProgramFiles\WindowsPowerShell\Modules\StatusCakeDSC\.securecreds" ))
            {
                throw "No credentials specified and no .securecreds file found"
            }
            else
            {
                $creds = Get-Content "$env:ProgramFiles\WindowsPowerShell\Modules\StatusCakeDSC\.securecreds" | ConvertFrom-Json 

                $secapikey = ConvertTo-SecureString $creds.ApiKey 
                $this.ApiCredential = [PSCredential]::new($creds.UserName, $secapikey)
            }
        }

        $headers = @{
            API = $this.ApiCredential.GetNetworkCredential().Password; 
            username = $this.ApiCredential.UserName
        }

        if($method -ne 'GET')
        {
            $splat = @{ 
                uri = "https://app.statuscake.com/API$stem";
                method = $method;
                body = $body;
                headers = $headers;
                ContentType = "application/x-www-form-urlencoded";
            }
        }
        else
        {
            $splat = @{
                uri = "https://app.statuscake.com/API$stem";
                method = "GET";
                headers = $headers;
            }
        }
 
        try {   
            $h = Invoke-WebRequest @splat -UseBasicParsing
            $httpresponse = $this.CopyObject($h)
            $httpresponse | Add-Member -MemberType NoteProperty -Name body -Value ($h.Content | ConvertFrom-Json)
        }
        catch{
            if($Error.Exception)
            {
                # if PS 6, we're shot. this'll work for PS5
                $r = $_.Exception.Response
                $httpresponse = $this.copyObject($r)
                $httpresponse | Add-Member -MemberType NoteProperty -Name body -Value ($r.Content | ConvertFrom-Json)
            }
            else {
                throw "No usable response received"
            }  
        }

        # SSL checks don't have an issues array like Tests. They have a Message field and a Success bool
        if($httpresponse.statuscode -ne 200 ) {
            throw ($httpresponse.body.message | out-string)
        }

        return $httpresponse.body 
    }

    [object] CopyObject([object]$from)
    {
        $to = [pscustomobject]@{}
        foreach ($p in Get-Member -In $from -MemberType Property -Name *)
        {  trap {
                Add-Member -In $To -MemberType NoteProperty -Name $p.Name -Value $From.$($p.Name) -Force
                continue
            }
            $to.$($p.Name) = $from.$($p.Name)
            # we know this throws, remove its error
            $Error.RemoveAt(0)
        }
        return $to
    }

    [Object] InvokeWithBackoff([scriptblock]$ScriptBlock) {
        
        $backoff = 1
        $retrycount = 0
        $returnvalue = $null
        while($returnvalue -eq $null -and $retrycount -lt $this.MaxRetries) {
            try {
                $returnvalue = Invoke-Command $ScriptBlock
            }
            catch
            {
                Write-Verbose ($error | Select-Object -first 1 )
                Start-Sleep -MilliSeconds ($backoff * 500)
                $backoff = $backoff + $backoff
                $retrycount++
                Write-Verbose "invoking a backoff: $backoff. We have tried $retrycount times"
            }
        }
    
        return $returnvalue
    }

    [int[]] ResolveContactGroups([string[]]$cgNames)
    {
        Write-Verbose "Resolving Contact Groups ($cgNames) to IDs"
        $groups = $this.GetApiResponse("/ContactGroups", 'GET', $null)
        $r = @()
        for($x=0;$x -lt $cgNames.Length;$x++) {
            Write-Verbose (" - Resolving group name " + $cgNames[$x])
            $r += ($groups | Where-Object { $_.GroupName -eq $cgNames[$x] } | Select-Object -expand ContactID)
        }
        Write-Verbose (" - Found Contact Groups " + ($r -join ","))
        return $r
    }

    [string[]] ResolveContactGroupIdsToNames([int[]]$contactGroupIds)
    {
        Write-Verbose "Resolving Contact Groups ($contactGroupIds) to names"
        $groups = $this.GetApiResponse("/ContactGroups", 'GET', $null)
        $r = @()
        for($x=0;$x -lt $contactGroupIds.Length;$x++) {
            Write-Verbose (" - Resolving group id " + $contactGroupIds[$x])
            $r += ($groups | Where-Object { $_.ContactID -eq $contactGroupIds[$x] } | Select-Object -expand GroupName)
        }
        Write-Verbose (" - Found Contact Groups " + ($r -join ","))
        return $r
    }

    [Object] GetObjectToPost([int]$id, [int[]]$ContactGroupID)
    {
        $p = 0
        if($this.paused -eq $true)
        {
            $p = 1
        }
        [String] $alertAt = ([string]$this.FinalReminderInDays + ',' + [string]$this.SecondReminderInDays + ',' + [string]$this.FirstReminderInDays)


        $r = @{
          domain = $this.Name #required - String
          checkrate = $this.CheckRate #required - integer
          contact_groups = ($ContactGroupID -join ",") #required - but can be an empty string
          alert_at = $alertAt #required - String FinalReminderInDays, SecondReminderInDays, FirstReminderInDays.
          alert_expiry = $this.AlertOnExpiration #required - Boolean
          alert_reminder = $this.AlertOnReminders #required - Boolean
          alert_broken = $this.AlertOnProblems #required - Boolean
          #paused = $p
        }
        
        # optionals
        <#
        if($ContactGroupID.length -gt 0)
        {
            Write-Verbose "Adding Contact group to post object"
            $r.add("ContactGroup", ($ContactGroupID -join ","))
        }
        #>

        if($this.BasicCredential -ne [PSCredential]::Empty)
        {
            Write-Verbose "Adding Basic Password and user"
            $r.Add("BasicUser", $this.BasicCredential.UserName)
            $r.Add("BasicPass", $this.BasicCredential.GetNetworkCredential().Password)
        }
        
        if($id -ne 0)
        {
            Write-Verbose "Adding a checkID to post object, as we're updating"
            $r.add("id", $id)
        }
        return $r
    }
}