From 9ee54d2aca8efa0cd80594e9010694ea4c32ba03 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Fri, 18 Sep 2026 23:07:41 -0500 Subject: [PATCH] feat: add Get-PiHoleStatsQuerySuggestions Adds a Metrics function for GET /queries/suggestions, which returns suggested filter values (domain, client IP/name, upstream, query type, status, reply, DNSSEC status) seen in recent queries - useful for building filters for the /queries endpoint. Includes an integration test verified against a real Pi-hole v6 server, and regenerates the README command reference to include it. Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 2 +- .../Get-PiHoleStatsQuerySuggestions.ps1 | 77 +++++++++++++++++++ README.md | 1 + ...tatsQuerySuggestions.Integration.Tests.ps1 | 47 +++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsQuerySuggestions.ps1 create mode 100644 tests/Get-PiHoleStatsQuerySuggestions.Integration.Tests.ps1 diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index efb4717..48bce96 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -28,7 +28,7 @@ Export-ModuleMember -Function @( #Padd 'Get-PiHolePadd', ` #Metrics - 'Get-PiHoleStatsRecentBlocked', 'Get-PiHoleStatsQueryType', 'Get-PiHoleStatsTopDomain', 'Get-PiHoleStatsSummary', 'Get-PiHoleStatsTopClient' ` + 'Get-PiHoleStatsRecentBlocked', 'Get-PiHoleStatsQueryType', 'Get-PiHoleStatsTopDomain', 'Get-PiHoleStatsSummary', 'Get-PiHoleStatsTopClient', 'Get-PiHoleStatsQuerySuggestions' ` #ListManagement 'Get-PiHoleList', 'Search-PiHoleListDomain', 'Add-PiHoleList', 'Remove-PiHoleList', ` #FTLInformation diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsQuerySuggestions.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQuerySuggestions.ps1 new file mode 100644 index 0000000..245bdb4 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQuerySuggestions.ps1 @@ -0,0 +1,77 @@ +function Get-PiHoleStatsQuerySuggestions { + <# +.SYNOPSIS +Get query filter suggestions + +.DESCRIPTION +Returns suggested values seen in recent queries for each filter accepted by the queries +endpoint: domain, client IP, client name, upstream, query type, status, reply, and DNSSEC status. + +.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 IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsQuerySuggestions -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/queries/suggestions')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "", Justification = "Suggestions is plural because the API returns multiple categories of suggested filter values")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/queries/suggestions" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Domain = $Response.suggestions.domain + ClientIp = $Response.suggestions.client_ip + ClientName = $Response.suggestions.client_name + Upstream = $Response.suggestions.upstream + Type = $Response.suggestions.type + Status = $Response.suggestions.status + Reply = $Response.suggestions.reply + Dnssec = $Response.suggestions.dnssec + } + Write-Output $Object + } + } + + 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 e575903..d508237 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Functions marked 🚧 are still under active development — signatures and outp | Function | Description | |---|---| +| `Get-PiHoleStatsQuerySuggestions` | Get query filter suggestions | | `Get-PiHoleStatsQueryType` | _No description yet_ | | `Get-PiHoleStatsRecentBlocked` | Request most recently blocked domain | | `Get-PiHoleStatsSummary` | Get overview of Pi-hole activity Request various query, system, and FTL properties | diff --git a/tests/Get-PiHoleStatsQuerySuggestions.Integration.Tests.ps1 b/tests/Get-PiHoleStatsQuerySuggestions.Integration.Tests.ps1 new file mode 100644 index 0000000..d74623a --- /dev/null +++ b/tests/Get-PiHoleStatsQuerySuggestions.Integration.Tests.ps1 @@ -0,0 +1,47 @@ +# 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. + +# Config availability must be known at discovery time so the -Skip parameter on each It block +# (evaluated during discovery, before BeforeAll runs) sees the correct value. +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsQuerySuggestions (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + # Recomputed here (not read from the discovery-time $script:ConfigAvailable above) because + # Pester runs discovery and run in separate scopes, so BeforeAll cannot see that value. + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns filter suggestions as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsQuerySuggestions -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + + $result | Should -Not -BeNullOrEmpty + $result.Type | Should -Not -BeNullOrEmpty + $result.Status | Should -Not -BeNullOrEmpty + $result.Reply | Should -Not -BeNullOrEmpty + $result.Dnssec | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsQuerySuggestions -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.suggestions | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsQuerySuggestions -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +}