From 0409f9429c172da9aafb895614af1e407c6ba984 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 19 Sep 2026 12:30:15 -0500 Subject: [PATCH 1/2] fix: stop leaking the DELETE /auth response into every function's output Remove-PiHoleCurrentAuthSession (called from every public function's finally block to close its session) called Invoke-RestMethod without suppressing its result, so whatever that DELETE call returned became an extra element appended to the calling function's own output. This turned every function's return value into a 2-element array (the real result plus an empty string) instead of the single object the API docs describe. Property access on the result silently filtered it out (accessing a missing property across a collection drops elements that lack it), so existing formatted output looked fine by accident, but RawOutput consumers and anything checking result shape got the polluted array. Discovered while writing tests for the new database-stats functions, where -RawOutput $result.PSObject.Properties.Name exposed the leak. Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/Private/Misc.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PiHoleShell/Private/Misc.ps1 b/PiHoleShell/Private/Misc.ps1 index b75175c..c9c50cc 100644 --- a/PiHoleShell/Private/Misc.ps1 +++ b/PiHoleShell/Private/Misc.ps1 @@ -64,7 +64,7 @@ function Remove-PiHoleCurrentAuthSession { } try { - Invoke-RestMethod @Params + $null = Invoke-RestMethod @Params } catch { From 3e0204d1f872fe510f35ed6e37a512e7710a5693 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 19 Sep 2026 12:30:31 -0500 Subject: [PATCH 2/2] feat: add Stats (database) functions Adds the six missing "Stats (database)" API operations identified earlier: the live /stats/upstreams endpoint (the only live stat that had no function yet) plus the five long-term-database equivalents of the existing live stats functions (summary, upstreams, top domains, top clients, query types). The database-backed endpoints require mandatory From/Until Unix-timestamp parameters, matching the API spec (fetched directly from the server's /api/docs/specs/stats.yaml). New functions: - Get-PiHoleStatsUpstream - Get-PiHoleStatsDatabaseUpstream - Get-PiHoleStatsDatabaseSummary - Get-PiHoleStatsDatabaseTopDomain - Get-PiHoleStatsDatabaseTopClient - Get-PiHoleStatsDatabaseQueryType All follow the module's standard try/catch/finally + session cleanup pattern and are registered in the export list. Regenerated the README command reference to include them. Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 3 +- .../Get-PiHoleStatsDatabaseQueryType.ps1 | 94 ++++++++++++++++ .../Get-PiHoleStatsDatabaseSummary.ps1 | 82 ++++++++++++++ .../Get-PiHoleStatsDatabaseTopClient.ps1 | 102 ++++++++++++++++++ .../Get-PiHoleStatsDatabaseTopDomain.ps1 | 101 +++++++++++++++++ .../Get-PiHoleStatsDatabaseUpstream.ps1 | 91 ++++++++++++++++ .../Metrics/Get-PiHoleStatsUpstream.ps1 | 81 ++++++++++++++ README.md | 6 ++ ...atsDatabaseQueryType.Integration.Tests.ps1 | 45 ++++++++ ...StatsDatabaseSummary.Integration.Tests.ps1 | 45 ++++++++ ...atsDatabaseTopClient.Integration.Tests.ps1 | 42 ++++++++ ...atsDatabaseTopDomain.Integration.Tests.ps1 | 42 ++++++++ ...tatsDatabaseUpstream.Integration.Tests.ps1 | 44 ++++++++ ...-PiHoleStatsUpstream.Integration.Tests.ps1 | 41 +++++++ 14 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseQueryType.ps1 create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseSummary.ps1 create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopClient.ps1 create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopDomain.ps1 create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseUpstream.ps1 create mode 100644 PiHoleShell/Public/Metrics/Get-PiHoleStatsUpstream.ps1 create mode 100644 tests/Get-PiHoleStatsDatabaseQueryType.Integration.Tests.ps1 create mode 100644 tests/Get-PiHoleStatsDatabaseSummary.Integration.Tests.ps1 create mode 100644 tests/Get-PiHoleStatsDatabaseTopClient.Integration.Tests.ps1 create mode 100644 tests/Get-PiHoleStatsDatabaseTopDomain.Integration.Tests.ps1 create mode 100644 tests/Get-PiHoleStatsDatabaseUpstream.Integration.Tests.ps1 create mode 100644 tests/Get-PiHoleStatsUpstream.Integration.Tests.ps1 diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index 48bce96..aa25829 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -28,7 +28,8 @@ Export-ModuleMember -Function @( #Padd 'Get-PiHolePadd', ` #Metrics - 'Get-PiHoleStatsRecentBlocked', 'Get-PiHoleStatsQueryType', 'Get-PiHoleStatsTopDomain', 'Get-PiHoleStatsSummary', 'Get-PiHoleStatsTopClient', 'Get-PiHoleStatsQuerySuggestions' ` + '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', ` #FTLInformation diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseQueryType.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseQueryType.ps1 new file mode 100644 index 0000000..ab1f931 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseQueryType.ps1 @@ -0,0 +1,94 @@ +function Get-PiHoleStatsDatabaseQueryType { + <# +.SYNOPSIS +Get query types (long-term database) + +.DESCRIPTION +Request a breakdown of query types (A, AAAA, ...) for a given time range from the long-term +(on-disk) database, rather than the in-memory data returned by Get-PiHoleStatsQueryType. + +.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 From +Unix timestamp from when the data should be requested + +.PARAMETER Until +Unix timestamp until when the data should be requested + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsDatabaseQueryType -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" -From 1672580025 -Until 1672666425 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/database/query_types')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$From, + [Parameter(Mandatory = $true)] + [int]$Until, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/stats/database/query_types?from=$From&until=$Until" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + A = $Response.types.A + AAAA = $Response.types.AAAA + ANY = $Response.types.ANY + SRV = $Response.types.SRV + SOA = $Response.types.SOA + PTR = $Response.types.PTR + TXT = $Response.types.TXT + NAPTR = $Response.types.NAPTR + MX = $Response.types.MX + DS = $Response.types.DS + RRSIG = $Response.types.RRSIG + DNSKEY = $Response.types.DNSKEY + NS = $Response.types.NS + SVCB = $Response.types.SVCB + HTTPS = $Response.types.HTTPS + OTHER = $Response.types.OTHER + } + Write-Output $Object + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseSummary.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseSummary.ps1 new file mode 100644 index 0000000..0b5c003 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseSummary.ps1 @@ -0,0 +1,82 @@ +function Get-PiHoleStatsDatabaseSummary { + <# +.SYNOPSIS +Get database content details + +.DESCRIPTION +Request various database content details (total/blocked queries, percent blocked, total +clients) for a given time range from the long-term (on-disk) database. + +.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 From +Unix timestamp from when the data should be requested + +.PARAMETER Until +Unix timestamp until when the data should be requested + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsDatabaseSummary -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" -From 1672580025 -Until 1672666425 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/database/summary')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$From, + [Parameter(Mandatory = $true)] + [int]$Until, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/stats/database/summary?from=$From&until=$Until" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + SumQueries = $Response.sum_queries + SumBlocked = $Response.sum_blocked + PercentBlocked = $Response.percent_blocked + TotalClients = $Response.total_clients + } + Write-Output $Object + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopClient.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopClient.ps1 new file mode 100644 index 0000000..4110f2c --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopClient.ps1 @@ -0,0 +1,102 @@ +function Get-PiHoleStatsDatabaseTopClient { + <# +.SYNOPSIS +Get top clients (long-term database) + +.DESCRIPTION +Request the top clients for a given time range from the long-term (on-disk) database, rather +than the in-memory data returned by Get-PiHoleStatsTopClient. + +.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 From +Unix timestamp from when the data should be requested + +.PARAMETER Until +Unix timestamp until when the data should be requested + +.PARAMETER MaxResult +How many results should be returned + +.PARAMETER Blocked +If true, returns top clients by blocked queries instead of total queries + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsDatabaseTopClient -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" -From 1672580025 -Until 1672666425 -MaxResult 10 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/database/top_clients')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$From, + [Parameter(Mandatory = $true)] + [int]$Until, + [int]$MaxResult = 10, + [bool]$Blocked = $false, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + switch ($Blocked) { + $false { $BlockedParam = "false" } + $true { $BlockedParam = "true" } + Default { throw "ERROR" } + } + Write-Verbose "Blocked: $BlockedParam" + Write-Verbose "MaxResult: $MaxResult" + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/stats/database/top_clients?from=$From&until=$Until&blocked=$BlockedParam&count=$MaxResult" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = @() + foreach ($Item in $Response.clients) { + $Object = [PSCustomObject]@{ + IP = $Item.ip + Name = $Item.name + Count = $Item.count + } + Write-Verbose -Message "Client - $($Item.ip) ($($Item.name)): $($Item.count)" + $ObjectFinal += $Object + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopDomain.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopDomain.ps1 new file mode 100644 index 0000000..214ec85 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseTopDomain.ps1 @@ -0,0 +1,101 @@ +function Get-PiHoleStatsDatabaseTopDomain { + <# +.SYNOPSIS +Get top domains (long-term database) + +.DESCRIPTION +Request the top domains for a given time range from the long-term (on-disk) database, rather +than the in-memory data returned by Get-PiHoleStatsTopDomain. + +.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 From +Unix timestamp from when the data should be requested + +.PARAMETER Until +Unix timestamp until when the data should be requested + +.PARAMETER MaxResult +How many results should be returned + +.PARAMETER Blocked +If true, returns top domains by blocked queries instead of total queries + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsDatabaseTopDomain -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" -From 1672580025 -Until 1672666425 -MaxResult 10 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/database/top_domains')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$From, + [Parameter(Mandatory = $true)] + [int]$Until, + [int]$MaxResult = 10, + [bool]$Blocked = $false, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + switch ($Blocked) { + $false { $BlockedParam = "false" } + $true { $BlockedParam = "true" } + Default { throw "ERROR" } + } + Write-Verbose "Blocked: $BlockedParam" + Write-Verbose "MaxResult: $MaxResult" + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/stats/database/top_domains?from=$From&until=$Until&blocked=$BlockedParam&count=$MaxResult" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = @() + foreach ($Item in $Response.domains) { + $Object = [PSCustomObject]@{ + Domain = $Item.domain + Count = $Item.count + } + Write-Verbose -Message "Domain - $($Item.domain): $($Item.count)" + $ObjectFinal += $Object + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseUpstream.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseUpstream.ps1 new file mode 100644 index 0000000..8cb0d44 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsDatabaseUpstream.ps1 @@ -0,0 +1,91 @@ +function Get-PiHoleStatsDatabaseUpstream { + <# +.SYNOPSIS +Get metrics about Pi-hole's upstream destinations (long-term database) + +.DESCRIPTION +Request upstream metrics from the long-term (on-disk) database for a given time range, rather +than the in-memory data returned by Get-PiHoleStatsUpstream. + +.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 From +Unix timestamp from when the data should be requested + +.PARAMETER Until +Unix timestamp until when the data should be requested + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleStatsDatabaseUpstream -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" -From 1672580025 -Until 1672666425 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/database/upstreams')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$From, + [Parameter(Mandatory = $true)] + [int]$Until, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/stats/database/upstreams?from=$From&until=$Until" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Upstreams = foreach ($Item in $Response.upstreams) { + [PSCustomObject]@{ + Ip = $Item.ip + Name = $Item.name + Port = $Item.port + Count = $Item.count + ResponseTime = $Item.statistics.response + Variance = $Item.statistics.variance + } + } + $Object = [PSCustomObject]@{ + TotalQueries = $Response.total_queries + ForwardedQueries = $Response.forwarded_queries + Upstreams = $Upstreams + } + Write-Output $Object + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsUpstream.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsUpstream.ps1 new file mode 100644 index 0000000..4ec0e36 --- /dev/null +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsUpstream.ps1 @@ -0,0 +1,81 @@ +function Get-PiHoleStatsUpstream { + <# +.SYNOPSIS +Get metrics about Pi-hole's upstream destinations + +.DESCRIPTION +Request upstream metrics: which upstream destinations have been used, how many queries each +has handled, and their average response time. + +.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-PiHoleStatsUpstream -PiHoleServer "http://pihole.domain.com:8080" -Password "fjdsjfldsjfkldjslafjskdl" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/stats/upstreams')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + 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/stats/upstreams" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Upstreams = foreach ($Item in $Response.upstreams) { + [PSCustomObject]@{ + Ip = $Item.ip + Name = $Item.name + Port = $Item.port + Count = $Item.count + ResponseTime = $Item.statistics.response + Variance = $Item.statistics.variance + } + } + $Object = [PSCustomObject]@{ + TotalQueries = $Response.total_queries + ForwardedQueries = $Response.forwarded_queries + Upstreams = $Upstreams + } + 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 d508237..21fc6e6 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,18 @@ Functions marked 🚧 are still under active development — signatures and outp | Function | Description | |---|---| +| `Get-PiHoleStatsDatabaseQueryType` | Get query types (long-term database) | +| `Get-PiHoleStatsDatabaseSummary` | Get database content details | +| `Get-PiHoleStatsDatabaseTopClient` | Get top clients (long-term database) | +| `Get-PiHoleStatsDatabaseTopDomain` | Get top domains (long-term database) | +| `Get-PiHoleStatsDatabaseUpstream` | Get metrics about Pi-hole's upstream destinations (long-term database) | | `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 | | `Get-PiHoleStatsTopClient` | Get top clients Request the top clients (by query count) | | `Get-PiHoleStatsTopDomain` | _No description yet_ | +| `Get-PiHoleStatsUpstream` | Get metrics about Pi-hole's upstream destinations | ### Configuration & Diagnostics diff --git a/tests/Get-PiHoleStatsDatabaseQueryType.Integration.Tests.ps1 b/tests/Get-PiHoleStatsDatabaseQueryType.Integration.Tests.ps1 new file mode 100644 index 0000000..fe70c55 --- /dev/null +++ b/tests/Get-PiHoleStatsDatabaseQueryType.Integration.Tests.ps1 @@ -0,0 +1,45 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsDatabaseQueryType (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # from=0 is rejected by the API with a 400; use a wide-but-valid recent window instead. + $script:Until = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:From = $script:Until - (30 * 86400) + } + + It 'returns a formatted object with all query type properties' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseQueryType -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'A' + $result.PSObject.Properties.Name | Should -Contain 'AAAA' + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseQueryType -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.PSObject.Properties.Name | Should -Contain 'types' + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseQueryType -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleStatsDatabaseSummary.Integration.Tests.ps1 b/tests/Get-PiHoleStatsDatabaseSummary.Integration.Tests.ps1 new file mode 100644 index 0000000..8f2dbdd --- /dev/null +++ b/tests/Get-PiHoleStatsDatabaseSummary.Integration.Tests.ps1 @@ -0,0 +1,45 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsDatabaseSummary (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # from=0 is rejected by the API with a 400; use a wide-but-valid recent window instead. + $script:Until = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:From = $script:Until - (30 * 86400) + } + + It 'returns database summary as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseSummary -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl + + $result | Should -Not -BeNullOrEmpty + $result.SumQueries | Should -BeGreaterOrEqual 0 + $result.SumBlocked | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseSummary -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.PSObject.Properties.Name | Should -Contain 'sum_queries' + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseSummary -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleStatsDatabaseTopClient.Integration.Tests.ps1 b/tests/Get-PiHoleStatsDatabaseTopClient.Integration.Tests.ps1 new file mode 100644 index 0000000..f62aa5c --- /dev/null +++ b/tests/Get-PiHoleStatsDatabaseTopClient.Integration.Tests.ps1 @@ -0,0 +1,42 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsDatabaseTopClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # from=0 is rejected by the API with a 400; use a wide-but-valid recent window instead. + $script:Until = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:From = $script:Until - (30 * 86400) + } + + It 'returns top clients without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleStatsDatabaseTopClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -MaxResult 5 } | + Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseTopClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.PSObject.Properties.Name | Should -Contain 'clients' + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseTopClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleStatsDatabaseTopDomain.Integration.Tests.ps1 b/tests/Get-PiHoleStatsDatabaseTopDomain.Integration.Tests.ps1 new file mode 100644 index 0000000..0821b53 --- /dev/null +++ b/tests/Get-PiHoleStatsDatabaseTopDomain.Integration.Tests.ps1 @@ -0,0 +1,42 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsDatabaseTopDomain (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # from=0 is rejected by the API with a 400; use a wide-but-valid recent window instead. + $script:Until = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:From = $script:Until - (30 * 86400) + } + + It 'returns top domains without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleStatsDatabaseTopDomain -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -MaxResult 5 } | + Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseTopDomain -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.PSObject.Properties.Name | Should -Contain 'domains' + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseTopDomain -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleStatsDatabaseUpstream.Integration.Tests.ps1 b/tests/Get-PiHoleStatsDatabaseUpstream.Integration.Tests.ps1 new file mode 100644 index 0000000..5130c86 --- /dev/null +++ b/tests/Get-PiHoleStatsDatabaseUpstream.Integration.Tests.ps1 @@ -0,0 +1,44 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsDatabaseUpstream (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # from=0 is rejected by the API with a 400; use a wide-but-valid recent window instead. + $script:Until = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $script:From = $script:Until - (30 * 86400) + } + + It 'returns upstream metrics as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseUpstream -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl + + $result | Should -Not -BeNullOrEmpty + $result.TotalQueries | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseUpstream -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.total_queries | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsDatabaseUpstream -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleStatsUpstream.Integration.Tests.ps1 b/tests/Get-PiHoleStatsUpstream.Integration.Tests.ps1 new file mode 100644 index 0000000..1f501c5 --- /dev/null +++ b/tests/Get-PiHoleStatsUpstream.Integration.Tests.ps1 @@ -0,0 +1,41 @@ +# 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 $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleStatsUpstream (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns upstream metrics as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsUpstream -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + + $result | Should -Not -BeNullOrEmpty + $result.Upstreams | Should -Not -BeNullOrEmpty + $result.TotalQueries | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsUpstream -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + + $result.upstreams | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleStatsUpstream -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +}