From b614506fa531e1010ea66a724317022377e14c31 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Fri, 25 Sep 2026 12:29:45 -0500 Subject: [PATCH 1/2] feat: complete the Info API area (10 new functions) Implements the remaining "FTL information" endpoints, closing 10 of the original 54 missing API operations: - Get-PiHoleInfoClient (/info/client) - info about the requesting client - Get-PiHoleInfoLogin (/info/login) - login page info - Get-PiHoleInfoSystem (/info/system) - uptime, memory, CPU - Get-PiHoleInfoFtl (/info/ftl) - gravity/group/list counts, dnsmasq metrics - Get-PiHoleInfoSensors (/info/sensors) - temperature sensors - Get-PiHoleInfoDatabase (/info/database) - long-term DB file/query stats - Get-PiHoleInfoVersion (/info/version) - Core/Web/FTL/Docker versions - Get-PiHoleInfoMetrics (/info/metrics) - live DNS/DHCP metrics - Get-PiHoleInfoMessageCount (/info/messages/count) - Remove-PiHoleInfoMessage (/info/messages/{id}) - supports comma-separated multiple IDs per the API Get-PiHoleInfoClient and Get-PiHoleInfoLogin intentionally have no -Password parameter and never call Request-PiHoleAuth: the live API spec marks both with `security: []`, and this was confirmed against the real server (both work with zero auth headers). This makes sense given their purpose - info needed to render the login page has to be available before you're logged in. Get-PiHoleInfoDatabase's earliest_timestamp/earliest_timestamp_disk are returned as $null (not epoch 1970) when the API sends 0, since the spec documents 0.0 as meaning "no queries stored yet" - the same zero-guard pattern already used for Get-PiHoleList's DateUpdated. Also fixed Get-PiHoleInfoMessage while in the area: it had the same stray break in its catch block fixed elsewhere in the module (silently aborts the calling script on error), and was missing a real .DESCRIPTION/.EXAMPLE. Verified all 10 new functions against a real Pi-hole v6 server: formatted output, RawOutput, and (where applicable) bad-password/not-found error handling. Added dedicated integration test files (33 tests total across the FTLInformation folder). README regenerated. Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 3 +- .../FTLInformation/Get-PiHoleInfoClient.ps1 | 65 ++++++++++ .../FTLInformation/Get-PiHoleInfoDatabase.ps1 | 95 ++++++++++++++ .../FTLInformation/Get-PiHoleInfoFtl.ps1 | 108 ++++++++++++++++ .../FTLInformation/Get-PiHoleInfoLogin.ps1 | 56 +++++++++ .../FTLInformation/Get-PiHoleInfoMessage.ps1 | 24 +++- .../Get-PiHoleInfoMessageCount.ps1 | 69 +++++++++++ .../FTLInformation/Get-PiHoleInfoMetrics.ps1 | 117 ++++++++++++++++++ .../FTLInformation/Get-PiHoleInfoSensors.ps1 | 91 ++++++++++++++ .../FTLInformation/Get-PiHoleInfoSystem.ps1 | 97 +++++++++++++++ .../FTLInformation/Get-PiHoleInfoVersion.ps1 | 106 ++++++++++++++++ .../Remove-PiHoleInfoMessage.ps1 | 83 +++++++++++++ README.md | 12 +- ...Get-PiHoleInfoClient.Integration.Tests.ps1 | 36 ++++++ ...t-PiHoleInfoDatabase.Integration.Tests.ps1 | 43 +++++++ .../Get-PiHoleInfoFtl.Integration.Tests.ps1 | 44 +++++++ .../Get-PiHoleInfoLogin.Integration.Tests.ps1 | 35 ++++++ ...HoleInfoMessageCount.Integration.Tests.ps1 | 42 +++++++ ...et-PiHoleInfoMetrics.Integration.Tests.ps1 | 43 +++++++ ...et-PiHoleInfoSensors.Integration.Tests.ps1 | 43 +++++++ ...Get-PiHoleInfoSystem.Integration.Tests.ps1 | 44 +++++++ ...et-PiHoleInfoVersion.Integration.Tests.ps1 | 43 +++++++ ...ve-PiHoleInfoMessage.Integration.Tests.ps1 | 37 ++++++ 23 files changed, 1331 insertions(+), 5 deletions(-) create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoClient.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoDatabase.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoFtl.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoLogin.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessageCount.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMetrics.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSensors.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSystem.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Get-PiHoleInfoVersion.ps1 create mode 100644 PiHoleShell/Public/FTLInformation/Remove-PiHoleInfoMessage.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoClient.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoDatabase.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoFtl.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoLogin.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoMessageCount.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoMetrics.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoSensors.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoSystem.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Get-PiHoleInfoVersion.Integration.Tests.ps1 create mode 100644 tests/FTLInformation/Remove-PiHoleInfoMessage.Integration.Tests.ps1 diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index 001e1ec..50f5085 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -33,7 +33,8 @@ Export-ModuleMember -Function @( #ListManagement 'Get-PiHoleList', 'Search-PiHoleListDomain', 'Add-PiHoleList', 'Remove-PiHoleList', ` #FTLInformation - 'Get-PiHoleInfoMessage', 'Get-PiHoleInfoHost', ` + 'Get-PiHoleInfoMessage', 'Get-PiHoleInfoHost', 'Get-PiHoleInfoClient', 'Get-PiHoleInfoLogin', 'Get-PiHoleInfoSystem', 'Get-PiHoleInfoFtl', ` + 'Get-PiHoleInfoSensors', 'Get-PiHoleInfoDatabase', 'Get-PiHoleInfoVersion', 'Get-PiHoleInfoMetrics', 'Get-PiHoleInfoMessageCount', 'Remove-PiHoleInfoMessage', ` #History 'Get-PiHoleHistory', 'Get-PiHoleHistoryDatabase', 'Get-PiHoleHistoryClient', 'Get-PiHoleHistoryDatabaseClient' ) \ No newline at end of file diff --git a/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoClient.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoClient.ps1 new file mode 100644 index 0000000..16b955d --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoClient.ps1 @@ -0,0 +1,65 @@ +function Get-PiHoleInfoClient { + <# +.SYNOPSIS +Get information about the requesting client + +.DESCRIPTION +Returns information about how the Pi-hole server sees this request: your remote address, the +HTTP version and method used, and the request headers sent. This endpoint does not require +authentication. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleInfoClient -PiHoleServer "http://pihole.domain.com:8080" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/client')] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Params = @{ + Uri = "$($PiHoleServer.OriginalString)/api/info/client" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Headers = foreach ($Item in $Response.headers) { + [PSCustomObject]@{ + Name = $Item.name + Value = $Item.value + } + } + + $Object = [PSCustomObject]@{ + RemoteAddress = $Response.remote_addr + HttpVersion = $Response.http_version + Method = $Response.method + Headers = $Headers + } + Write-Output $Object + } + } + + catch { + Write-Error -Message $_.Exception.Message + } +} diff --git a/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoDatabase.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoDatabase.ps1 new file mode 100644 index 0000000..a5b791a --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoDatabase.ps1 @@ -0,0 +1,95 @@ +function Get-PiHoleInfoDatabase { + <# +.SYNOPSIS +Get info about the long-term database + +.DESCRIPTION +Request details about Pi-hole's long-term (on-disk) query database file: its size and +ownership on disk, how many queries are stored in-memory versus on-disk, and the earliest +timestamp in each. + +.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-PiHoleInfoDatabase -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/database')] + [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/info/database" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + # The API defaults these to 0.0 when there are no queries stored in-memory/on-disk yet. + $EarliestTimestamp = if ($Response.earliest_timestamp -eq 0) { $null } else { (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Response.earliest_timestamp).LocalTime } + $EarliestTimestampDisk = if ($Response.earliest_timestamp_disk -eq 0) { $null } else { (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Response.earliest_timestamp_disk).LocalTime } + + $Object = [PSCustomObject]@{ + Size = $Response.size + Type = $Response.type + Mode = $Response.mode + AccessTime = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Response.atime).LocalTime + ModifiedTime = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Response.mtime).LocalTime + ChangeTime = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Response.ctime).LocalTime + Owner = [PSCustomObject]@{ + User = [PSCustomObject]@{ + Uid = $Response.owner.user.uid + Name = $Response.owner.user.name + Info = $Response.owner.user.info + } + Group = [PSCustomObject]@{ + Gid = $Response.owner.group.gid + Name = $Response.owner.group.name + } + } + Queries = $Response.queries + EarliestTimestamp = $EarliestTimestamp + QueriesDisk = $Response.queries_disk + EarliestTimestampDisk = $EarliestTimestampDisk + SqliteVersion = $Response.sqlite_version + } + 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/FTLInformation/Get-PiHoleInfoFtl.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoFtl.ps1 new file mode 100644 index 0000000..cfee248 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoFtl.ps1 @@ -0,0 +1,108 @@ +function Get-PiHoleInfoFtl { + <# +.SYNOPSIS +Get info about various FTL parameters + +.DESCRIPTION +Request a collection of FTL process information: gravity database counts, privacy level, +query frequency, client counts, process ID/uptime/resource usage, whether destructive actions +are allowed, and metrics from the embedded dnsmasq resolver. + +.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-PiHoleInfoFtl -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/ftl')] + [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/info/ftl" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Database = [PSCustomObject]@{ + Gravity = $Response.ftl.database.gravity + Antigravity = $Response.ftl.database.antigravity + Groups = $Response.ftl.database.groups + Lists = $Response.ftl.database.lists + Clients = $Response.ftl.database.clients + Domains = [PSCustomObject]@{ + Allowed = [PSCustomObject]@{ + Total = $Response.ftl.database.domains.allowed.total + Enabled = $Response.ftl.database.domains.allowed.enabled + } + Denied = [PSCustomObject]@{ + Total = $Response.ftl.database.domains.denied.total + Enabled = $Response.ftl.database.domains.denied.enabled + } + } + Regex = [PSCustomObject]@{ + Allowed = [PSCustomObject]@{ + Total = $Response.ftl.database.regex.allowed.total + Enabled = $Response.ftl.database.regex.allowed.enabled + } + Denied = [PSCustomObject]@{ + Total = $Response.ftl.database.regex.denied.total + Enabled = $Response.ftl.database.regex.denied.enabled + } + } + } + PrivacyLevel = $Response.ftl.privacy_level + QueryFrequency = $Response.ftl.query_frequency + Clients = [PSCustomObject]@{ + Total = $Response.ftl.clients.total + Active = $Response.ftl.clients.active + } + Pid = $Response.ftl.pid + Uptime = $Response.ftl.uptime + PercentMemory = $Response.ftl.'%mem' + PercentCpu = $Response.ftl.'%cpu' + AllowDestructive = $Response.ftl.allow_destructive + Dnsmasq = ConvertTo-PiHolePascalCaseObject -InputObject $Response.ftl.dnsmasq + } + 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/FTLInformation/Get-PiHoleInfoLogin.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoLogin.ps1 new file mode 100644 index 0000000..914c889 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoLogin.ps1 @@ -0,0 +1,56 @@ +function Get-PiHoleInfoLogin { + <# +.SYNOPSIS +Get login page information + +.DESCRIPTION +Returns information used on Pi-hole's login page (the HTTPS port, and whether the DNS server +is currently running). This endpoint does not require authentication, since it's meant to be +usable before logging in. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleInfoLogin -PiHoleServer "http://pihole.domain.com:8080" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/login')] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Params = @{ + Uri = "$($PiHoleServer.OriginalString)/api/info/login" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + HttpsPort = $Response.https_port + Dns = $Response.dns + } + Write-Output $Object + } + } + + catch { + Write-Error -Message $_.Exception.Message + } +} diff --git a/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessage.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessage.ps1 index fcd4f28..c067a3e 100644 --- a/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessage.ps1 +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessage.ps1 @@ -2,9 +2,28 @@ function Get-PiHoleInfoMessage { <# .SYNOPSIS Get Pi-hole diagnosis messages -Request Pi-hole diagnosis messages + +.DESCRIPTION +Request Pi-hole's diagnosis messages - warnings FTL has generated about its own configuration +or operation (e.g. rate-limiting a noisy client). See Get-PiHoleInfoMessageCount for just the +count, and Remove-PiHoleInfoMessage to dismiss one. + +.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-PiHoleInfoMessage -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" #> - [CmdletBinding()] + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/messages')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] param ( [Parameter(Mandatory = $true)] @@ -58,7 +77,6 @@ Request Pi-hole diagnosis messages catch { Write-Error -Message $_.Exception.Message - break } finally { diff --git a/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessageCount.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessageCount.ps1 new file mode 100644 index 0000000..b5f98f4 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMessageCount.ps1 @@ -0,0 +1,69 @@ +function Get-PiHoleInfoMessageCount { + <# +.SYNOPSIS +Get count of Pi-hole diagnosis messages + +.DESCRIPTION +Request the number of Pi-hole diagnosis messages currently outstanding. See +Get-PiHoleInfoMessage for the messages themselves, and Remove-PiHoleInfoMessage to dismiss one. + +.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-PiHoleInfoMessageCount -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/messages/count')] + [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/info/messages/count" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Count = $Response.count + } + 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/FTLInformation/Get-PiHoleInfoMetrics.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMetrics.ps1 new file mode 100644 index 0000000..22263a5 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoMetrics.ps1 @@ -0,0 +1,117 @@ +function Get-PiHoleInfoMetrics { + <# +.SYNOPSIS +Get metrics info + +.DESCRIPTION +Request live DNS and DHCP metrics: DNS cache statistics (including a per-record-type +breakdown), reply-type counts, and DHCP message/lease counts. + +.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-PiHoleInfoMetrics -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/metrics')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "", Justification = "Metrics matches the Pi-hole API's own endpoint name, /info/metrics")] + 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/info/metrics" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $CacheContent = foreach ($Item in $Response.metrics.dns.cache.content) { + [PSCustomObject]@{ + Type = $Item.type + Name = $Item.name + Count = [PSCustomObject]@{ + Valid = $Item.count.valid + Stale = $Item.count.stale + } + } + } + + $Object = [PSCustomObject]@{ + Dns = [PSCustomObject]@{ + Cache = [PSCustomObject]@{ + Size = $Response.metrics.dns.cache.size + Inserted = $Response.metrics.dns.cache.inserted + Evicted = $Response.metrics.dns.cache.evicted + Expired = $Response.metrics.dns.cache.expired + Immortal = $Response.metrics.dns.cache.immortal + Content = $CacheContent + } + Replies = [PSCustomObject]@{ + Forwarded = $Response.metrics.dns.replies.forwarded + Unanswered = $Response.metrics.dns.replies.unanswered + Local = $Response.metrics.dns.replies.local + Optimized = $Response.metrics.dns.replies.optimized + Auth = $Response.metrics.dns.replies.auth + Sum = $Response.metrics.dns.replies.sum + } + } + Dhcp = [PSCustomObject]@{ + Ack = $Response.metrics.dhcp.ack + Nak = $Response.metrics.dhcp.nak + Decline = $Response.metrics.dhcp.decline + Offer = $Response.metrics.dhcp.offer + Discover = $Response.metrics.dhcp.discover + Inform = $Response.metrics.dhcp.inform + Request = $Response.metrics.dhcp.request + Release = $Response.metrics.dhcp.release + NoAnswer = $Response.metrics.dhcp.noanswer + Bootp = $Response.metrics.dhcp.bootp + Pxe = $Response.metrics.dhcp.pxe + Leases = [PSCustomObject]@{ + Allocated4 = $Response.metrics.dhcp.leases.allocated_4 + Pruned4 = $Response.metrics.dhcp.leases.pruned_4 + Allocated6 = $Response.metrics.dhcp.leases.allocated_6 + Pruned6 = $Response.metrics.dhcp.leases.pruned_6 + } + } + } + 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/FTLInformation/Get-PiHoleInfoSensors.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSensors.ps1 new file mode 100644 index 0000000..8d056f7 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSensors.ps1 @@ -0,0 +1,91 @@ +function Get-PiHoleInfoSensors { + <# +.SYNOPSIS +Get info about various sensors + +.DESCRIPTION +Request temperature sensor information, including Pi-hole's best guess at the CPU +temperature and the configured "hot" limit. + +.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-PiHoleInfoSensors -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/sensors')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "", Justification = "Sensors matches the Pi-hole API's own endpoint name, /info/sensors")] + 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/info/sensors" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $List = foreach ($Item in $Response.sensors.list) { + $Temps = foreach ($Temp in $Item.temps) { + [PSCustomObject]@{ + Name = $Temp.name + Value = $Temp.value + Max = $Temp.max + Crit = $Temp.crit + Sensor = $Temp.sensor + } + } + [PSCustomObject]@{ + Name = $Item.name + Path = $Item.path + Source = $Item.source + Temps = $Temps + } + } + + $Object = [PSCustomObject]@{ + List = $List + CpuTemp = $Response.sensors.cpu_temp + HotLimit = $Response.sensors.hot_limit + Unit = $Response.sensors.unit + } + 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/FTLInformation/Get-PiHoleInfoSystem.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSystem.ps1 new file mode 100644 index 0000000..cc148e1 --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoSystem.ps1 @@ -0,0 +1,97 @@ +function Get-PiHoleInfoSystem { + <# +.SYNOPSIS +Get info about various system parameters + +.DESCRIPTION +Request system information: uptime, RAM/swap memory usage, process count, and CPU usage/load +averages, including FTL's own share of memory and CPU. + +.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-PiHoleInfoSystem -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/system')] + [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/info/system" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Uptime = $Response.system.uptime + Memory = [PSCustomObject]@{ + Ram = [PSCustomObject]@{ + Total = $Response.system.memory.ram.total + Free = $Response.system.memory.ram.free + Used = $Response.system.memory.ram.used + Available = $Response.system.memory.ram.available + PercentUsed = $Response.system.memory.ram.'%used' + } + Swap = [PSCustomObject]@{ + Total = $Response.system.memory.swap.total + Free = $Response.system.memory.swap.free + Used = $Response.system.memory.swap.used + PercentUsed = $Response.system.memory.swap.'%used' + } + } + Procs = $Response.system.procs + Cpu = [PSCustomObject]@{ + NumProcessors = $Response.system.cpu.nprocs + PercentCpu = $Response.system.cpu.'%cpu' + Load = [PSCustomObject]@{ + Raw = $Response.system.cpu.load.raw + Percent = $Response.system.cpu.load.percent + } + } + Ftl = [PSCustomObject]@{ + PercentMemory = $Response.system.ftl.'%mem' + PercentCpu = $Response.system.ftl.'%cpu' + } + } + 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/FTLInformation/Get-PiHoleInfoVersion.ps1 b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoVersion.ps1 new file mode 100644 index 0000000..f6a6b3b --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Get-PiHoleInfoVersion.ps1 @@ -0,0 +1,106 @@ +function Get-PiHoleInfoVersion { + <# +.SYNOPSIS +Get Pi-hole version + +.DESCRIPTION +Request the local and remote (latest available) versions of each Pi-hole component: Core, +Web, FTL, and the Docker image (if running in Docker). + +.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-PiHoleInfoVersion -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/info/version')] + [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/info/version" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Core = [PSCustomObject]@{ + Local = [PSCustomObject]@{ + Branch = $Response.version.core.local.branch + Version = $Response.version.core.local.version + Hash = $Response.version.core.local.hash + } + Remote = [PSCustomObject]@{ + Version = $Response.version.core.remote.version + Hash = $Response.version.core.remote.hash + } + } + Web = [PSCustomObject]@{ + Local = [PSCustomObject]@{ + Branch = $Response.version.web.local.branch + Version = $Response.version.web.local.version + Hash = $Response.version.web.local.hash + } + Remote = [PSCustomObject]@{ + Version = $Response.version.web.remote.version + Hash = $Response.version.web.remote.hash + } + } + Ftl = [PSCustomObject]@{ + Local = [PSCustomObject]@{ + Branch = $Response.version.ftl.local.branch + Version = $Response.version.ftl.local.version + Hash = $Response.version.ftl.local.hash + Date = $Response.version.ftl.local.date + } + Remote = [PSCustomObject]@{ + Version = $Response.version.ftl.remote.version + Hash = $Response.version.ftl.remote.hash + } + } + Docker = [PSCustomObject]@{ + Local = $Response.version.docker.local + Remote = $Response.version.docker.remote + } + } + 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/FTLInformation/Remove-PiHoleInfoMessage.ps1 b/PiHoleShell/Public/FTLInformation/Remove-PiHoleInfoMessage.ps1 new file mode 100644 index 0000000..53b498f --- /dev/null +++ b/PiHoleShell/Public/FTLInformation/Remove-PiHoleInfoMessage.ps1 @@ -0,0 +1,83 @@ +function Remove-PiHoleInfoMessage { + <# +.SYNOPSIS +Delete a Pi-hole diagnosis message + +.DESCRIPTION +Dismisses one or more Pi-hole diagnosis messages by ID. See Get-PiHoleInfoMessage to list +messages and find their IDs. + +.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 MessageId +The ID (or IDs) of the message(s) to delete + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Remove-PiHoleInfoMessage -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -MessageId 3 + +.EXAMPLE +Remove-PiHoleInfoMessage -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -MessageId 1,2,3 + #> + [CmdletBinding(SupportsShouldProcess = $true, HelpUri = 'https://ftl.pi-hole.net/master/docs/#delete-/info/messages/-message_id-')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int[]]$MessageId, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $MessageIdPath = $MessageId -join ',' + + if ($PSCmdlet.ShouldProcess("Pi-Hole diagnosis message(s) $MessageIdPath on $PiHoleServer", "Delete")) { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/info/messages/$MessageIdPath" + Method = "Delete" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + # A successful delete is HTTP 204 No Content, so there's no response body to parse. + $Object = [PSCustomObject]@{ + MessageId = $MessageId + Status = "Deleted" + } + 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 2a7c11a..5cce8e3 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,19 @@ Functions marked 🚧 are still under active development — signatures and outp | `Get-PiHoleHistoryClient` | Get per-client activity graph data | | `Get-PiHoleHistoryDatabase` | Get activity graph data (long-term data) | | `Get-PiHoleHistoryDatabaseClient` | Get per-client activity graph data (long-term data) | +| `Get-PiHoleInfoClient` | Get information about the requesting client | +| `Get-PiHoleInfoDatabase` | Get info about the long-term database | +| `Get-PiHoleInfoFtl` | Get info about various FTL parameters | | `Get-PiHoleInfoHost` | Get information about the host system | -| `Get-PiHoleInfoMessage` | Get Pi-hole diagnosis messages Request Pi-hole diagnosis messages | +| `Get-PiHoleInfoLogin` | Get login page information | +| `Get-PiHoleInfoMessage` | Get Pi-hole diagnosis messages | +| `Get-PiHoleInfoMessageCount` | Get count of Pi-hole diagnosis messages | +| `Get-PiHoleInfoMetrics` | Get metrics info | +| `Get-PiHoleInfoSensors` | Get info about various sensors | +| `Get-PiHoleInfoSystem` | Get info about various system parameters | +| `Get-PiHoleInfoVersion` | Get Pi-hole version | | `Get-PiHolePadd` | Get summarized data for PADD | +| `Remove-PiHoleInfoMessage` | Delete a Pi-hole diagnosis message | ### Authentication diff --git a/tests/FTLInformation/Get-PiHoleInfoClient.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoClient.Integration.Tests.ps1 new file mode 100644 index 0000000..add9b14 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoClient.Integration.Tests.ps1 @@ -0,0 +1,36 @@ +# 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 'Get-PiHoleInfoClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns client info as a formatted object without needing a password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoClient -PiHoleServer $script:PiHoleServer -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.RemoteAddress | Should -Not -BeNullOrEmpty + $result.Headers | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoClient -PiHoleServer $script:PiHoleServer -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.remote_addr | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoDatabase.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoDatabase.Integration.Tests.ps1 new file mode 100644 index 0000000..3ef996a --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoDatabase.Integration.Tests.ps1 @@ -0,0 +1,43 @@ +# 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 'Get-PiHoleInfoDatabase (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns database info as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoDatabase -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Size | Should -BeGreaterThan 0 + $result.SqliteVersion | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoDatabase -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.sqlite_version | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoDatabase -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoFtl.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoFtl.Integration.Tests.ps1 new file mode 100644 index 0000000..7b4e2c3 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoFtl.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleInfoFtl (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns FTL info as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoFtl -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Pid | Should -BeGreaterThan 0 + $result.Database.Gravity | Should -BeGreaterOrEqual 0 + $result.Dnsmasq | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoFtl -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.ftl | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoFtl -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoLogin.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoLogin.Integration.Tests.ps1 new file mode 100644 index 0000000..92a5eaa --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoLogin.Integration.Tests.ps1 @@ -0,0 +1,35 @@ +# 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 'Get-PiHoleInfoLogin (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns login page info as a formatted object without needing a password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoLogin -PiHoleServer $script:PiHoleServer -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.HttpsPort | Should -BeGreaterThan 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoLogin -PiHoleServer $script:PiHoleServer -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.https_port | Should -BeGreaterThan 0 + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoMessageCount.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoMessageCount.Integration.Tests.ps1 new file mode 100644 index 0000000..c2fb490 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoMessageCount.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleInfoMessageCount (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns the message count as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMessageCount -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMessageCount -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.count | Should -BeGreaterOrEqual 0 + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMessageCount -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoMetrics.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoMetrics.Integration.Tests.ps1 new file mode 100644 index 0000000..eaa2e06 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoMetrics.Integration.Tests.ps1 @@ -0,0 +1,43 @@ +# 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 'Get-PiHoleInfoMetrics (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns metrics as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMetrics -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Dns.Cache | Should -Not -BeNullOrEmpty + $result.Dhcp | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMetrics -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.metrics | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoMetrics -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoSensors.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoSensors.Integration.Tests.ps1 new file mode 100644 index 0000000..c85b638 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoSensors.Integration.Tests.ps1 @@ -0,0 +1,43 @@ +# 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 'Get-PiHoleInfoSensors (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns sensor info as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSensors -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.List | Should -Not -BeNullOrEmpty + $result.Unit | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSensors -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.sensors | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSensors -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoSystem.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoSystem.Integration.Tests.ps1 new file mode 100644 index 0000000..ec12516 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoSystem.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleInfoSystem (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns system info as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSystem -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Uptime | Should -BeGreaterThan 0 + $result.Memory.Ram.Total | Should -BeGreaterThan 0 + $result.Cpu.Load | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSystem -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.system | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoSystem -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Get-PiHoleInfoVersion.Integration.Tests.ps1 b/tests/FTLInformation/Get-PiHoleInfoVersion.Integration.Tests.ps1 new file mode 100644 index 0000000..ea98313 --- /dev/null +++ b/tests/FTLInformation/Get-PiHoleInfoVersion.Integration.Tests.ps1 @@ -0,0 +1,43 @@ +# 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 'Get-PiHoleInfoVersion (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'returns version info as a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoVersion -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Core.Local.Version | Should -Not -BeNullOrEmpty + $result.Ftl.Local.Version | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoVersion -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.version | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleInfoVersion -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/FTLInformation/Remove-PiHoleInfoMessage.Integration.Tests.ps1 b/tests/FTLInformation/Remove-PiHoleInfoMessage.Integration.Tests.ps1 new file mode 100644 index 0000000..c541a48 --- /dev/null +++ b/tests/FTLInformation/Remove-PiHoleInfoMessage.Integration.Tests.ps1 @@ -0,0 +1,37 @@ +# 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. +# +# NOTE: Diagnosis messages arise from real FTL warnings (e.g. rate-limiting a client) and can't +# be manufactured on demand, so there's no reliable way to test a genuine successful delete here. +# These tests instead verify the error paths, which still exercise the real request/auth flow. + +$script:ConfigAvailable = Test-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Remove-PiHoleInfoMessage (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + } + + It 'errors when the message does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Remove-PiHoleInfoMessage -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -MessageId 999999 -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Remove-PiHoleInfoMessage -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -MessageId 1 -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} From dc6b84b28b6690c45f06888fbd5d4df599e2d6b3 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Fri, 25 Sep 2026 16:03:38 -0500 Subject: [PATCH 2/2] 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 + } +}