diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index 50f5085..a28485d 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -31,7 +31,7 @@ Export-ModuleMember -Function @( 'Get-PiHoleStatsRecentBlocked', 'Get-PiHoleStatsQueryType', 'Get-PiHoleStatsTopDomain', 'Get-PiHoleStatsSummary', 'Get-PiHoleStatsTopClient', 'Get-PiHoleStatsQuerySuggestions', ` 'Get-PiHoleStatsUpstream', 'Get-PiHoleStatsDatabaseUpstream', 'Get-PiHoleStatsDatabaseSummary', 'Get-PiHoleStatsDatabaseTopDomain', 'Get-PiHoleStatsDatabaseTopClient', 'Get-PiHoleStatsDatabaseQueryType' ` #ListManagement - 'Get-PiHoleList', 'Search-PiHoleListDomain', 'Add-PiHoleList', 'Remove-PiHoleList', ` + 'Get-PiHoleList', 'Search-PiHoleListDomain', 'Add-PiHoleList', 'Remove-PiHoleList', 'Update-PiHoleList', ` #FTLInformation 'Get-PiHoleInfoMessage', 'Get-PiHoleInfoHost', 'Get-PiHoleInfoClient', 'Get-PiHoleInfoLogin', 'Get-PiHoleInfoSystem', 'Get-PiHoleInfoFtl', ` 'Get-PiHoleInfoSensors', 'Get-PiHoleInfoDatabase', 'Get-PiHoleInfoVersion', 'Get-PiHoleInfoMetrics', 'Get-PiHoleInfoMessageCount', 'Remove-PiHoleInfoMessage', ` diff --git a/PiHoleShell/Public/ListManagement/Update-PiHoleList.ps1 b/PiHoleShell/Public/ListManagement/Update-PiHoleList.ps1 new file mode 100644 index 0000000..d6789fe --- /dev/null +++ b/PiHoleShell/Public/ListManagement/Update-PiHoleList.ps1 @@ -0,0 +1,152 @@ +function Update-PiHoleList { + <# +.SYNOPSIS +Update a list + +.DESCRIPTION +Updates an existing list's Comment, Group(s), and/or Enabled state. The underlying Pi-hole API +replaces the entire list on update, so any property you don't pass here is preserved by first +reading the list's current value and resending it - nothing is silently cleared just because +you only meant to change one property. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER Address +The URL of the list to update + +.PARAMETER Type +Whether this is an Allow list or a Block list + +.PARAMETER Comment +The new comment for the list. Leave unset to keep the list's current comment + +.PARAMETER Group +The group(s) this list should apply to. Leave unset to keep the list's current group(s) + +.PARAMETER Enabled +Whether the list should be enabled. Leave unset to keep the list's current state + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Update-PiHoleList -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Address "https://hosts-file.net/ad_servers.txt" -Type Block -Enabled $false + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#put-/lists/-list-')] + [Diagnostics.CodeAnalysis.SuppressMessage("PSUseShouldProcessForStateChangingFunctions", "", Justification = "Ignoring for now")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [System.Uri]$Address, + [Parameter(Mandatory = $true)] + [ValidateSet("Allow", "Block")] + [string]$Type, + [string]$Comment, + [string[]]$Group, + [Nullable[bool]]$Enabled, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + if (-not $PSBoundParameters.ContainsKey('Comment') -and -not $PSBoundParameters.ContainsKey('Group') -and -not $PSBoundParameters.ContainsKey('Enabled')) { + throw "To update $Address, you must specify the Comment, Group, and/or Enabled parameter" + } + + $ExistingList = Get-PiHoleList -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -List $Address | Where-Object { $_.Type -eq $Type } + + if (-not $ExistingList) { + throw "Cannot find $Address of type $Type on $PiHoleServer! Please use Add-PiHoleList to create it" + } + + $AllGroups = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $GroupNamesToResolve = if ($PSBoundParameters.ContainsKey('Group')) { $Group } else { $ExistingList.Groups } + + $AllGroupsNames = @() + $AllGroupsIds = @() + foreach ($GroupItem in $GroupNamesToResolve) { + $FoundGroup = $AllGroups | Where-Object { $_.Name -eq $GroupItem } + if ($FoundGroup) { + $AllGroupsNames += $FoundGroup.Name + $AllGroupsIds += $FoundGroup.Id + } + else { + throw "Cannot find $GroupItem on $PiHoleServer! Please use Get-PiHoleGroup to list all groups" + } + } + + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + # The API replaces the whole list on update, so any property not explicitly passed here + # is resent using the list's current value to avoid silently clearing it. + $Body = @{ + comment = if ($PSBoundParameters.ContainsKey('Comment')) { $Comment } else { $ExistingList.Comment } + groups = [Object[]]($AllGroupsIds) + enabled = if ($PSBoundParameters.ContainsKey('Enabled')) { $Enabled } else { $ExistingList.Enabled } + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/lists/$Address`?type=$($Type.ToLower())" + Method = "Put" + SkipCertificateCheck = $IgnoreSsl + Body = $Body | ConvertTo-Json -Depth 10 + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = foreach ($Item in $Response.lists) { + if ($Item.date_updated -eq 0) { + $DateUpdated = $null + } + else { + $DateUpdated = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + } + + [PSCustomObject]@{ + Address = $Item.address + Comment = $Item.comment + Groups = $AllGroupsNames + Enabled = $Item.enabled + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + Type = $Item.type.SubString(0, 1).ToUpper() + $Item.type.SubString(1).ToLower() + DateUpdated = $DateUpdated + Number = $Item.number + InvalidDomains = $Item.invalid_domains + AbpEntries = $Item.abp_entries + Status = $Item.status + } + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/README.md b/README.md index 5cce8e3..b4711ea 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Functions marked 🚧 are still under active development — signatures and outp | `Get-PiHoleList` 🚧 | Get lists | | `Remove-PiHoleList` | Remove a list | | `Search-PiHoleListDomain` | _No description yet_ | +| `Update-PiHoleList` | Update a list | ### Metrics diff --git a/tests/ListManagement/Update-PiHoleList.Integration.Tests.ps1 b/tests/ListManagement/Update-PiHoleList.Integration.Tests.ps1 new file mode 100644 index 0000000..61d73bd --- /dev/null +++ b/tests/ListManagement/Update-PiHoleList.Integration.Tests.ps1 @@ -0,0 +1,94 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Update-PiHoleList (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestListAddress = 'https://blocklistproject.github.io/Lists/alt-version/ransomware-nl.txt' + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Defensive cleanup in case a previous failed run left the test list behind + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + } + + It 'updates only the comment, preserving Enabled and Group' -Skip:(-not $script:ConfigAvailable) { + Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Comment 'original comment' -Enabled $true | Out-Null + + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Comment 'updated comment' + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'updated comment' + $result.Enabled | Should -BeTrue + $result.Groups | Should -Contain 'Default' + + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false | Out-Null + } + + It 'updates only Enabled, preserving the current comment' -Skip:(-not $script:ConfigAvailable) { + Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Comment 'keep this comment' -Enabled $true | Out-Null + + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Enabled $false + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'keep this comment' + $result.Enabled | Should -BeFalse + + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false | Out-Null + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block | Out-Null + + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Comment 'raw output test' -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.lists[0].comment | Should -Be 'raw output test' + + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false | Out-Null + } + + It 'errors when neither Comment, Group, nor Enabled is specified' -Skip:(-not $script:ConfigAvailable) { + Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block | Out-Null + + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false | Out-Null + } + + It 'errors when the list does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address 'https://example.com/does-not-exist.txt' -Type Block -Comment 'irrelevant' -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block | Out-Null + + $result = Update-PiHoleList -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Comment 'irrelevant' -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +}