From dc6b84b28b6690c45f06888fbd5d4df599e2d6b3 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Fri, 25 Sep 2026 16:03:38 -0500 Subject: [PATCH] feat: add Update-PiHoleList, completing the Lists API area Implements PUT /api/lists/{list}, the last missing piece of List management - Add-PiHoleList's own "already exists" error has referenced this function by name since it was written. Updates an existing list's Comment, Group(s), and/or Enabled state. Like Update-PiHoleGroup, the underlying API replaces the entire list on update, so this reads the list's current values first and only overrides whichever of Comment/Group/Enabled was actually passed - using the same $PSBoundParameters.ContainsKey() pattern (rather than checking parameter values for $null), since that was the actual root cause of the bug fixed in Update-PiHoleGroup: an unbound [string] binds to "", and [bool] can never be $null, so value-based checks can't reliably detect "not passed". Verified type only needs to be a query parameter (?type=block), not also in the request body, by testing directly against the real server - the API's own documented PUT body schema lists `type` as a body field too, but (like the POST endpoint's documented schema, which turned out to expect type in the query string only despite listing it in the body schema) the server accepted both a query-only and query+body request identically. The other remaining Lists gap, DELETE /lists/{list} (single item), isn't being added as a separate function: Remove-PiHoleList already achieves the same outcome via the batch endpoint (a batch of one) - same reasoning already applied to skip the deprecated POST /action/flush/arp. Verified against a real Pi-hole v6 server: partial updates (confirming the untouched field is preserved), RawOutput, and not-found/missing-parameter/ bad-password errors. Added a dedicated integration test file (6 tests). README regenerated. Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 2 +- .../ListManagement/Update-PiHoleList.ps1 | 152 ++++++++++++++++++ README.md | 1 + .../Update-PiHoleList.Integration.Tests.ps1 | 94 +++++++++++ 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 PiHoleShell/Public/ListManagement/Update-PiHoleList.ps1 create mode 100644 tests/ListManagement/Update-PiHoleList.Integration.Tests.ps1 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 + } +}