From 93c69c75ab145efe35a1f2ea510cc6aca99d16fd Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sun, 27 Sep 2026 12:12:38 -0500 Subject: [PATCH] feat: add Client Management and Network Information, completing both API areas Adds Get/New/Update/Remove-PiHoleClient and Get-PiHoleClientSuggestion, covering Pi-hole's client management API (GET/POST/PUT + batch-delete, plus unconfigured-client suggestions). Single-item DELETE is intentionally skipped in favor of the batch endpoint, same precedent as Remove-PiHoleList/Remove-PiHoleDomain. Adds Get-PiHoleNetworkGateway/Route/Interface/Device and Remove-PiHoleNetworkDevice, covering the full Network Information API. Unlike Clients/Domains/Lists there's no batch-delete alternative for devices, so Remove-PiHoleNetworkDevice implements the single-item DELETE directly. Interface/route/gateway responses vary significantly by type and aren't fully enumerable from the spec, so they're returned via the existing generic PascalCase-conversion helper (already used by Get-PiHoleConfig) rather than a hand-picked set of fields - confirmed against the real server that this also correctly surfaces at least one live field not documented in the OpenAPI spec (interface stats.bits). Adds Client Management as its own README/EXAMPLES category; Network Information folds into "Configuration & Diagnostics" like the other read-only diagnostic areas (FTLInformation, Padd, Teleporter, History). Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 6 +- .../ClientManagement/Get-PiHoleClient.ps1 | 97 ++++ .../Get-PiHoleClientSuggestion.ps1 | 75 +++ .../ClientManagement/New-PiHoleClient.ps1 | 121 +++++ .../ClientManagement/Remove-PiHoleClient.ps1 | 90 +++ .../ClientManagement/Update-PiHoleClient.ps1 | 129 +++++ .../Get-PiHoleNetworkDevice.ps1 | 110 ++++ .../Get-PiHoleNetworkGateway.ps1 | 77 +++ .../Get-PiHoleNetworkInterface.ps1 | 77 +++ .../Get-PiHoleNetworkRoute.ps1 | 77 +++ .../Remove-PiHoleNetworkDevice.ps1 | 80 +++ README.md | 15 + docs/EXAMPLES.md | 511 ++++++++++++------ .../Get-PiHoleClient.Integration.Tests.ps1 | 62 +++ ...HoleClientSuggestion.Integration.Tests.ps1 | 38 ++ .../New-PiHoleClient.Integration.Tests.ps1 | 99 ++++ .../Remove-PiHoleClient.Integration.Tests.ps1 | 67 +++ .../Update-PiHoleClient.Integration.Tests.ps1 | 109 ++++ ...-PiHoleNetworkDevice.Integration.Tests.ps1 | 44 ++ ...PiHoleNetworkGateway.Integration.Tests.ps1 | 45 ++ ...HoleNetworkInterface.Integration.Tests.ps1 | 45 ++ ...t-PiHoleNetworkRoute.Integration.Tests.ps1 | 45 ++ ...-PiHoleNetworkDevice.Integration.Tests.ps1 | 38 ++ tools/Update-ExampleOutput.ps1 | 58 ++ tools/Update-ReadmeCommandReference.ps1 | 3 +- 25 files changed, 1949 insertions(+), 169 deletions(-) create mode 100644 PiHoleShell/Public/ClientManagement/Get-PiHoleClient.ps1 create mode 100644 PiHoleShell/Public/ClientManagement/Get-PiHoleClientSuggestion.ps1 create mode 100644 PiHoleShell/Public/ClientManagement/New-PiHoleClient.ps1 create mode 100644 PiHoleShell/Public/ClientManagement/Remove-PiHoleClient.ps1 create mode 100644 PiHoleShell/Public/ClientManagement/Update-PiHoleClient.ps1 create mode 100644 PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkDevice.ps1 create mode 100644 PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkGateway.ps1 create mode 100644 PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkInterface.ps1 create mode 100644 PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkRoute.ps1 create mode 100644 PiHoleShell/Public/NetworkInformation/Remove-PiHoleNetworkDevice.ps1 create mode 100644 tests/ClientManagement/Get-PiHoleClient.Integration.Tests.ps1 create mode 100644 tests/ClientManagement/Get-PiHoleClientSuggestion.Integration.Tests.ps1 create mode 100644 tests/ClientManagement/New-PiHoleClient.Integration.Tests.ps1 create mode 100644 tests/ClientManagement/Remove-PiHoleClient.Integration.Tests.ps1 create mode 100644 tests/ClientManagement/Update-PiHoleClient.Integration.Tests.ps1 create mode 100644 tests/NetworkInformation/Get-PiHoleNetworkDevice.Integration.Tests.ps1 create mode 100644 tests/NetworkInformation/Get-PiHoleNetworkGateway.Integration.Tests.ps1 create mode 100644 tests/NetworkInformation/Get-PiHoleNetworkInterface.Integration.Tests.ps1 create mode 100644 tests/NetworkInformation/Get-PiHoleNetworkRoute.Integration.Tests.ps1 create mode 100644 tests/NetworkInformation/Remove-PiHoleNetworkDevice.Integration.Tests.ps1 diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index 59967d5..da20726 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -40,5 +40,9 @@ Export-ModuleMember -Function @( #Teleporter 'Get-PiHoleTeleporterDownload', ` #DomainManagement - 'Get-PiHoleDomain', 'New-PiHoleDomain', 'Update-PiHoleDomain', 'Remove-PiHoleDomain' + 'Get-PiHoleDomain', 'New-PiHoleDomain', 'Update-PiHoleDomain', 'Remove-PiHoleDomain', ` + #ClientManagement + 'Get-PiHoleClient', 'New-PiHoleClient', 'Update-PiHoleClient', 'Remove-PiHoleClient', 'Get-PiHoleClientSuggestion', ` + #NetworkInformation + 'Get-PiHoleNetworkGateway', 'Get-PiHoleNetworkRoute', 'Get-PiHoleNetworkInterface', 'Get-PiHoleNetworkDevice', 'Remove-PiHoleNetworkDevice' ) \ No newline at end of file diff --git a/PiHoleShell/Public/ClientManagement/Get-PiHoleClient.ps1 b/PiHoleShell/Public/ClientManagement/Get-PiHoleClient.ps1 new file mode 100644 index 0000000..799ff99 --- /dev/null +++ b/PiHoleShell/Public/ClientManagement/Get-PiHoleClient.ps1 @@ -0,0 +1,97 @@ +function Get-PiHoleClient { + <# +.SYNOPSIS +Get clients + +.DESCRIPTION +Request Pi-hole's configured clients (used to apply group-based rules to specific devices). +Omit -Client to get every configured client; specify it to get just that 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 Client +A specific client to return - an IP address, IP subnet (CIDR), MAC address, hostname, or +interface (prefixed with a colon, e.g. ":eth0"). Omit to return every configured client + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Client "192.168.1.50" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/clients/-client-')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [string]$Client, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Groups = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Uri = "$($PiHoleServer.OriginalString)/api/clients" + if ($Client) { + $Uri += "/$([System.Uri]::EscapeDataString($Client))" + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = $Uri + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + + else { + $ObjectFinal = foreach ($Item in $Response.clients) { + $GroupNames = [System.Collections.ArrayList]@() + foreach ($Group in $Item.groups) { + $GroupNames += ($Groups | Where-Object { $_.Id -eq $Group }).Name + } + + [PSCustomObject]@{ + Client = $Item.client + Name = $Item.name + Comment = $Item.comment + Groups = $GroupNames + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + } + } + 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/ClientManagement/Get-PiHoleClientSuggestion.ps1 b/PiHoleShell/Public/ClientManagement/Get-PiHoleClientSuggestion.ps1 new file mode 100644 index 0000000..e1ca8fc --- /dev/null +++ b/PiHoleShell/Public/ClientManagement/Get-PiHoleClientSuggestion.ps1 @@ -0,0 +1,75 @@ +function Get-PiHoleClientSuggestion { + <# +.SYNOPSIS +Get client suggestions + +.DESCRIPTION +Returns a list of clients Pi-hole has seen making DNS queries but that aren't yet configured as +a client (see New-PiHoleClient to add 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-PiHoleClientSuggestion -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/clients/_suggestions')] + [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/clients/_suggestions" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + + else { + $ObjectFinal = foreach ($Item in $Response.clients) { + [PSCustomObject]@{ + HwAddr = $Item.hwaddr + MacVendor = $Item.macVendor + LastQuery = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.lastQuery).LocalTime + Addresses = $Item.addresses + Names = $Item.names + } + } + 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/ClientManagement/New-PiHoleClient.ps1 b/PiHoleShell/Public/ClientManagement/New-PiHoleClient.ps1 new file mode 100644 index 0000000..703d473 --- /dev/null +++ b/PiHoleShell/Public/ClientManagement/New-PiHoleClient.ps1 @@ -0,0 +1,121 @@ +function New-PiHoleClient { + <# +.SYNOPSIS +Add a new client + +.DESCRIPTION +Adds a client to Pi-hole so group-based rules can be applied to it specifically. A client may +be identified by IP address, IP subnet (CIDR notation), MAC address, hostname, or the interface +it connects through (prefixed with a colon, e.g. ":eth0"). IP-based recognition is preferred - +MAC address, hostname, and interface recognition only work for devices Pi-hole has already seen. + +.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 Client +The client to add - an IP address, IP subnet (CIDR), MAC address, hostname, or interface +(prefixed with a colon, e.g. ":eth0") + +.PARAMETER Comment +An optional comment to store alongside the client + +.PARAMETER Group +The group(s) this client applies to. Defaults to "Default" + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +New-PiHoleClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Client "192.168.1.50" -Comment "Kid's tablet" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#post-/clients')] + [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)] + [string]$Client, + [string]$Comment = $null, + [string[]]$Group = "Default", + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $FindMatchingClient = Get-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -Client $Client + + if ($FindMatchingClient) { + throw "Client $Client already exists on $PiHoleServer! Please use Update-PiHoleClient to update it" + } + + $AllGroups = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + $AllGroupsNames = @() + $AllGroupsIds = @() + foreach ($GroupItem in $Group) { + $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 + + $Body = @{ + client = $Client + comment = $Comment + groups = [Object[]]($AllGroupsIds) + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/clients" + Method = "Post" + 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.clients) { + [PSCustomObject]@{ + Client = $Item.client + Name = $Item.name + Comment = $Item.comment + Groups = $AllGroupsNames + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + } + } + 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/ClientManagement/Remove-PiHoleClient.ps1 b/PiHoleShell/Public/ClientManagement/Remove-PiHoleClient.ps1 new file mode 100644 index 0000000..0855c67 --- /dev/null +++ b/PiHoleShell/Public/ClientManagement/Remove-PiHoleClient.ps1 @@ -0,0 +1,90 @@ +function Remove-PiHoleClient { + <# +.SYNOPSIS +Remove a client + +.DESCRIPTION +Removes a client from Pi-hole. The Pi-hole API deletes clients in a batch, so this sends a +single-item batch containing just the client you specify. + +.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 Client +The client to remove - an IP address, IP subnet (CIDR), MAC address, hostname, or interface +(prefixed with a colon, e.g. ":eth0") + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Remove-PiHoleClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Client "192.168.1.50" + #> + [CmdletBinding(SupportsShouldProcess = $true, HelpUri = 'https://ftl.pi-hole.net/master/docs/#post-/clients-batchDelete')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [string]$Client, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Target = "Pi-Hole client $Client" + if ($PSCmdlet.ShouldProcess($Target, "Remove client")) { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Body = @( + @{ + item = $Client + } + ) + + #For some reason this needs to be here to make it an array + $Body = , $Body + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/clients:batchDelete" + Method = "Post" + SkipCertificateCheck = $IgnoreSsl + Body = $Body | ConvertTo-Json -Depth 10 -Compress + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + + else { + # A successful delete returns 204 No Content, so there's no response body to + # build a rich object from. + $Object = [PSCustomObject]@{ + Client = $Client + Status = "Removed" + } + 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/ClientManagement/Update-PiHoleClient.ps1 b/PiHoleShell/Public/ClientManagement/Update-PiHoleClient.ps1 new file mode 100644 index 0000000..f7319af --- /dev/null +++ b/PiHoleShell/Public/ClientManagement/Update-PiHoleClient.ps1 @@ -0,0 +1,129 @@ +function Update-PiHoleClient { + <# +.SYNOPSIS +Update a client + +.DESCRIPTION +Updates an existing client's Comment and/or Group(s). The underlying Pi-hole API replaces the +entire client entry on update, so any property you don't pass here is preserved by first +reading the client'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 Client +The client to update - an IP address, IP subnet (CIDR), MAC address, hostname, or interface +(prefixed with a colon, e.g. ":eth0") + +.PARAMETER Comment +The new comment for the client. Leave unset to keep the client's current comment + +.PARAMETER Group +The group(s) this client should apply to. Leave unset to keep the client's current group(s) + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Update-PiHoleClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Client "192.168.1.50" -Comment "Kid's tablet" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#put-/clients/-client-')] + [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)] + [string]$Client, + [string]$Comment, + [string[]]$Group, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + if (-not $PSBoundParameters.ContainsKey('Comment') -and -not $PSBoundParameters.ContainsKey('Group')) { + throw "To update $Client, you must specify the Comment and/or Group parameter" + } + + $ExistingClient = Get-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -Client $Client + + if (-not $ExistingClient) { + throw "Cannot find $Client on $PiHoleServer! Please use New-PiHoleClient to create it" + } + + $AllGroups = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $GroupNamesToResolve = if ($PSBoundParameters.ContainsKey('Group')) { $Group } else { $ExistingClient.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 client entry on update, so any property not explicitly + # passed here is resent using the client's current value to avoid silently clearing it. + $Body = @{ + comment = if ($PSBoundParameters.ContainsKey('Comment')) { $Comment } else { $ExistingClient.Comment } + groups = [Object[]]($AllGroupsIds) + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/clients/$([System.Uri]::EscapeDataString($Client))" + 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.clients) { + [PSCustomObject]@{ + Client = $Item.client + Name = $Item.name + Comment = $Item.comment + Groups = $AllGroupsNames + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + } + } + 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/NetworkInformation/Get-PiHoleNetworkDevice.ps1 b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkDevice.ps1 new file mode 100644 index 0000000..f47050b --- /dev/null +++ b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkDevice.ps1 @@ -0,0 +1,110 @@ +function Get-PiHoleNetworkDevice { + <# +.SYNOPSIS +Get info about the devices in your local network as seen by your Pi-hole + +.DESCRIPTION +Returns the devices Pi-hole has seen on your network, ordered by most recent query first. Shown +devices default to 10; use -MaxDevices to change that. + +.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 MaxDevices +Maximum number of devices to return. Defaults to 10 + +.PARAMETER MaxAddresses +Maximum number of addresses to return per device + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleNetworkDevice -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleNetworkDevice -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -MaxDevices 50 + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/network/devices')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Nullable[int]]$MaxDevices, + [Nullable[int]]$MaxAddresses, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $QueryParams = [System.Collections.ArrayList]@() + if ($PSBoundParameters.ContainsKey('MaxDevices')) { + $QueryParams.Add("max_devices=$MaxDevices") | Out-Null + } + if ($PSBoundParameters.ContainsKey('MaxAddresses')) { + $QueryParams.Add("max_addresses=$MaxAddresses") | Out-Null + } + + $Uri = "$($PiHoleServer.OriginalString)/api/network/devices" + if ($QueryParams.Count -gt 0) { + $Uri += "?" + ($QueryParams -join '&') + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = $Uri + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = foreach ($Item in $Response.devices) { + $Ips = foreach ($IpItem in $Item.ips) { + [PSCustomObject]@{ + Ip = $IpItem.ip + Name = $IpItem.name + LastSeen = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $IpItem.lastSeen).LocalTime + NameUpdated = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $IpItem.nameUpdated).LocalTime + } + } + + [PSCustomObject]@{ + Id = $Item.id + HwAddr = $Item.hwaddr + Interface = $Item.interface + FirstSeen = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.firstSeen).LocalTime + LastQuery = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.lastQuery).LocalTime + NumQueries = $Item.numQueries + MacVendor = $Item.macVendor + Ips = $Ips + } + } + 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/NetworkInformation/Get-PiHoleNetworkGateway.ps1 b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkGateway.ps1 new file mode 100644 index 0000000..ee6c1d3 --- /dev/null +++ b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkGateway.ps1 @@ -0,0 +1,77 @@ +function Get-PiHoleNetworkGateway { + <# +.SYNOPSIS +Get info about the gateway of your Pi-hole + +.DESCRIPTION +Returns information about your Pi-hole's network gateway(s). The response shape varies by +address family and interface type, so it's returned as nested objects with PascalCase property +names rather than a hand-picked subset - use -RawOutput to see the untouched API response. + +.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 Detailed +Include detailed information about the individual interfaces and routes. Available fields +depend on the interface type and state + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleNetworkGateway -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/network/gateway')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Nullable[bool]]$Detailed, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Uri = "$($PiHoleServer.OriginalString)/api/network/gateway" + if ($PSBoundParameters.ContainsKey('Detailed')) { + $Uri += "?detailed=$($Detailed.ToString().ToLower())" + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = $Uri + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = ConvertTo-PiHolePascalCaseObject -InputObject $Response.gateway + 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/NetworkInformation/Get-PiHoleNetworkInterface.ps1 b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkInterface.ps1 new file mode 100644 index 0000000..8efbc42 --- /dev/null +++ b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkInterface.ps1 @@ -0,0 +1,77 @@ +function Get-PiHoleNetworkInterface { + <# +.SYNOPSIS +Get info about the interfaces of your Pi-hole + +.DESCRIPTION +Returns information about your Pi-hole's networking interfaces. Not every field applies to +every interface type, so the response is returned as nested objects with PascalCase property +names rather than a hand-picked subset - use -RawOutput to see the untouched API response. + +.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 Detailed +Include more detailed information about the individual interfaces. Available fields depend on +the interface type and state + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleNetworkInterface -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/network/interfaces')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Nullable[bool]]$Detailed, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Uri = "$($PiHoleServer.OriginalString)/api/network/interfaces" + if ($PSBoundParameters.ContainsKey('Detailed')) { + $Uri += "?detailed=$($Detailed.ToString().ToLower())" + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = $Uri + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = ConvertTo-PiHolePascalCaseObject -InputObject $Response.interfaces + 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/NetworkInformation/Get-PiHoleNetworkRoute.ps1 b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkRoute.ps1 new file mode 100644 index 0000000..e6f3740 --- /dev/null +++ b/PiHoleShell/Public/NetworkInformation/Get-PiHoleNetworkRoute.ps1 @@ -0,0 +1,77 @@ +function Get-PiHoleNetworkRoute { + <# +.SYNOPSIS +Get info about the routes of your Pi-hole + +.DESCRIPTION +Returns information about your Pi-hole's networking routes. Not every field applies to every +route type, so the response is returned as nested objects with PascalCase property names +rather than a hand-picked subset - use -RawOutput to see the untouched API response. + +.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 Detailed +Include more detailed information about the individual routes. Available fields depend on the +route type and state + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleNetworkRoute -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/network/routes')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Nullable[bool]]$Detailed, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Uri = "$($PiHoleServer.OriginalString)/api/network/routes" + if ($PSBoundParameters.ContainsKey('Detailed')) { + $Uri += "?detailed=$($Detailed.ToString().ToLower())" + } + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = $Uri + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = ConvertTo-PiHolePascalCaseObject -InputObject $Response.routes + 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/NetworkInformation/Remove-PiHoleNetworkDevice.ps1 b/PiHoleShell/Public/NetworkInformation/Remove-PiHoleNetworkDevice.ps1 new file mode 100644 index 0000000..aac1ee4 --- /dev/null +++ b/PiHoleShell/Public/NetworkInformation/Remove-PiHoleNetworkDevice.ps1 @@ -0,0 +1,80 @@ +function Remove-PiHoleNetworkDevice { + <# +.SYNOPSIS +Delete a device from the network table + +.DESCRIPTION +Deletes a device from Pi-hole's network table, along with all its associated IP addresses and +hostnames. Use Get-PiHoleNetworkDevice to find the device's Id first. + +.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 DeviceId +The Id of the device to remove, as shown by Get-PiHoleNetworkDevice + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Remove-PiHoleNetworkDevice -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -DeviceId 5 + #> + [CmdletBinding(SupportsShouldProcess = $true, HelpUri = 'https://ftl.pi-hole.net/master/docs/#delete-/network/devices/-id-')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [int]$DeviceId, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + try { + $Target = "Pi-Hole network device $DeviceId" + if ($PSCmdlet.ShouldProcess($Target, "Remove device")) { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/network/devices/$DeviceId" + Method = "Delete" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + + else { + # A successful delete returns 204 No Content, so there's no response body to + # build a rich object from. + $Object = [PSCustomObject]@{ + DeviceId = $DeviceId + Status = "Removed" + } + 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 37671ed..d1cf8e7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,16 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Remove-PiHoleDomain` | Remove a domain | | `Update-PiHoleDomain` | Update a domain | +### Client Management + +| Function | Description | +|---|---| +| `Get-PiHoleClient` | Get clients | +| `Get-PiHoleClientSuggestion` | Get client suggestions | +| `New-PiHoleClient` | Add a new client | +| `Remove-PiHoleClient` | Remove a client | +| `Update-PiHoleClient` | Update a client | + ### Metrics | Function | Description | @@ -168,9 +178,14 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Get-PiHoleInfoSystem` | Get info about various system parameters | | `Get-PiHoleInfoVersion` | Get Pi-hole version | | `Get-PiHoleLogWebserver` | Get webserver log content | +| `Get-PiHoleNetworkDevice` | Get info about the devices in your local network as seen by your Pi-hole | +| `Get-PiHoleNetworkGateway` | Get info about the gateway of your Pi-hole | +| `Get-PiHoleNetworkInterface` | Get info about the interfaces of your Pi-hole | +| `Get-PiHoleNetworkRoute` | Get info about the routes of your Pi-hole | | `Get-PiHolePadd` | Get summarized data for PADD | | `Get-PiHoleTeleporterDownload` | Export Pi-hole settings | | `Remove-PiHoleInfoMessage` | Delete a Pi-hole diagnosis message | +| `Remove-PiHoleNetworkDevice` | Delete a device from the network table | ### Authentication diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index a0deb07..1cc2273 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -100,8 +100,8 @@ Name : PiHoleShellDocsExampleGroup Comment : Example group Enabled : True Id : 4 -DateAdded : 9/27/2026 7:45:33 AM -DateModified : 9/27/2026 7:45:33 AM +DateAdded : 9/27/2026 11:54:34 AM +DateModified : 9/27/2026 11:54:34 AM ``` ### Get-PiHoleGroup @@ -137,8 +137,8 @@ Name : PiHoleShellDocsExampleGroup Comment : Example group Enabled : True Id : 4 -DateAdded : 9/27/2026 7:45:33 AM -DateModified : 9/27/2026 7:45:33 AM +DateAdded : 9/27/2026 11:54:34 AM +DateModified : 9/27/2026 11:54:34 AM ``` ### Update-PiHoleGroup @@ -153,8 +153,8 @@ Name : PiHoleShellDocsExampleGroup Comment : Example group Enabled : False Id : 4 -DateAdded : 9/27/2026 7:45:33 AM -DateModified : 9/27/2026 7:45:51 AM +DateAdded : 9/27/2026 11:54:34 AM +DateModified : 9/27/2026 11:55:06 AM ``` ### Remove-PiHoleGroup @@ -184,8 +184,8 @@ Comment : Example list Groups : {Default} Enabled : True Id : 65 -DateAdded : 9/27/2026 7:46:28 AM -DateModified : 9/27/2026 7:46:28 AM +DateAdded : 9/27/2026 11:56:17 AM +DateModified : 9/27/2026 11:56:17 AM Type : Block DateUpdated : Number : 0 @@ -214,7 +214,7 @@ DateUpdated : 7/6/2025 2:20:13 AM Number : 74761 InvalidDomains : 1 AbpEntries : 0 -Status : 1 +Status : 2 Address : https://adaway.org/hosts.txt Comment : @@ -288,8 +288,8 @@ Comment : Example list Groups : {Default} Enabled : False Id : 65 -DateAdded : 9/27/2026 7:46:28 AM -DateModified : 9/27/2026 7:47:01 AM +DateAdded : 9/27/2026 11:56:17 AM +DateModified : 9/27/2026 11:57:19 AM Type : Block DateUpdated : Number : 0 @@ -319,7 +319,7 @@ DateUpdated : 9/26/2026 11:28:08 PM Number : 74761 InvalidDomains : 1 AbpEntries : 0 -Status : 1 +Status : 2 Domain : doubleclick.net Address : https://adaway.org/hosts.txt @@ -414,9 +414,9 @@ Kind : Exact Comment : Example domain Groups : {Default} Enabled : True -Id : 22 -DateAdded : 9/27/2026 7:47:39 AM -DateModified : 9/27/2026 7:47:39 AM +Id : 12 +DateAdded : 9/27/2026 11:58:32 AM +DateModified : 9/27/2026 11:58:32 AM ``` ### Get-PiHoleDomain @@ -434,9 +434,20 @@ Kind : exact Comment : Example domain Groups : {Default} Enabled : True -Id : 22 -DateAdded : 9/27/2026 7:47:39 AM -DateModified : 9/27/2026 7:47:39 AM +Id : 12 +DateAdded : 9/27/2026 11:58:32 AM +DateModified : 9/27/2026 11:58:32 AM + +Domain : piholeshell-test-domain.example.com +Unicode : piholeshell-test-domain.example.com +Type : allow +Kind : exact +Comment : +Groups : {Default} +Enabled : True +Id : 13 +DateAdded : 9/27/2026 11:58:40 AM +DateModified : 9/27/2026 11:58:40 AM ``` ### Update-PiHoleDomain @@ -454,9 +465,9 @@ Kind : Exact Comment : Example domain Groups : {Default} Enabled : False -Id : 22 -DateAdded : 9/27/2026 7:47:39 AM -DateModified : 9/27/2026 7:48:12 AM +Id : 12 +DateAdded : 9/27/2026 11:58:32 AM +DateModified : 9/27/2026 11:59:34 AM ``` ### Remove-PiHoleDomain @@ -473,6 +484,81 @@ Kind : Exact Status : Removed ``` +## Client Management + +### New-PiHoleClient + +```powershell +New-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password -Client "192.168.99.99" -Comment "Example client" +``` + +``` + +Client : 192.168.99.99 +Name : +Comment : Example client +Groups : {Default} +Id : 1 +DateAdded : 9/27/2026 12:00:33 PM +DateModified : 9/27/2026 12:00:33 PM +``` + +### Get-PiHoleClient + +```powershell +Get-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password +``` + +``` + +Client : 192.168.99.99 +Name : +Comment : Example client +Groups : {Default} +Id : 1 +DateAdded : 9/27/2026 12:00:33 PM +DateModified : 9/27/2026 12:00:33 PM +``` + +### Get-PiHoleClientSuggestion + +```powershell +Get-PiHoleClientSuggestion -PiHoleServer $PiHoleServer -Password $Password +``` + +``` +(no output) +``` + +### Update-PiHoleClient + +```powershell +Update-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password -Client "192.168.99.99" -Comment "Updated comment" +``` + +``` + +Client : 192.168.99.99 +Name : +Comment : Updated comment +Groups : {Default} +Id : 1 +DateAdded : 9/27/2026 12:00:33 PM +DateModified : 9/27/2026 12:01:45 PM +``` + +### Remove-PiHoleClient + +```powershell +Remove-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password -Client "192.168.99.99" +``` + +``` + +Client : 192.168.99.99 +Status : Removed +``` + ## Metrics ### Get-PiHoleStatsSummary @@ -483,18 +569,18 @@ Get-PiHoleStatsSummary -PiHoleServer $PiHoleServer -Password $Password ``` -Total : 281 -Blocked : 84 -PercentBlocked : 29.893238067627 -UniqueDomains : 21 -Forwarded : 37 -Cached : 160 +Total : 0 +Blocked : 0 +PercentBlocked : 0 +UniqueDomains : 0 +Forwarded : 0 +Cached : 0 Frequency : 0 -Types : @{A=130; AAAA=130; ANY=0; SRV=0; SOA=0; PTR=21; TXT=0; NAPTR=0; MX=0; DS=0; RRSIG=0; DNSKEY=0; NS=0; SVCB=0; HTTPS=0; OTHER=0} -Status : @{Unknown=0; Gravity=84; Forwarded=37; Cache=139; Regex=0; DenyList=0; ExternalBlockedIp=0; ExternalBlockedNull=0; ExternalBlockedNxra=0; GravityCname=0; RegexCname=0; DenyListCname=0; Retired=0; RetiredDnssec=0; InProgress=0; Dbbusy=0; SpecialDomain=0; CacheStale=21; ExternalBlockedEde15=0} -Replies : @{Unknown=12; Nodata=17; Nxdomain=3; Cname=0; Ip=231; Domain=18; Rrname=0; ServFail=0; Refused=0; Notimp=0; Other=0; Dnssec=0; None=0; Blob=0} -Clients : @{Active=2; Total=2} -Gravity : @{DomainsBeingBlocked=499226; LastUpdate=1790483337} +Types : @{A=0; AAAA=0; ANY=0; SRV=0; SOA=0; PTR=0; TXT=0; NAPTR=0; MX=0; DS=0; RRSIG=0; DNSKEY=0; NS=0; SVCB=0; HTTPS=0; OTHER=0} +Status : @{Unknown=0; Gravity=0; Forwarded=0; Cache=0; Regex=0; DenyList=0; ExternalBlockedIp=0; ExternalBlockedNull=0; ExternalBlockedNxra=0; GravityCname=0; RegexCname=0; DenyListCname=0; Retired=0; RetiredDnssec=0; InProgress=0; Dbbusy=0; SpecialDomain=0; CacheStale=0; ExternalBlockedEde15=0} +Replies : @{Unknown=0; Nodata=0; Nxdomain=0; Cname=0; Ip=0; Domain=0; Rrname=0; ServFail=0; Refused=0; Notimp=0; Other=0; Dnssec=0; None=0; Blob=0} +Clients : @{Active=0; Total=0} +Gravity : @{DomainsBeingBlocked=496171; LastUpdate=1790527593} ``` ### Get-PiHoleStatsRecentBlocked @@ -504,8 +590,7 @@ Get-PiHoleStatsRecentBlocked -PiHoleServer $PiHoleServer -Password $Password ``` ``` - -Blocked : googleadservices.com +(no output) ``` ### Get-PiHoleStatsQueryType @@ -517,10 +602,10 @@ Get-PiHoleStatsQueryType -PiHoleServer $PiHoleServer -Password $Password ``` Type : A -Count : 130 +Count : 0 Type : AAAA -Count : 130 +Count : 0 Type : ANY Count : 0 @@ -541,23 +626,7 @@ Get-PiHoleStatsTopDomain -PiHoleServer $PiHoleServer -Password $Password ``` ``` - -Domain : wikipedia.org -Count : 20 - -Domain : bbc.com -Count : 18 - -Domain : python.org -Count : 18 - -Domain : example.com -Count : 18 - -Domain : mozilla.org -Count : 16 - -_(showing 5 of 10 results)_ +(no output) ``` ### Get-PiHoleStatsTopClient @@ -567,14 +636,7 @@ Get-PiHoleStatsTopClient -PiHoleServer $PiHoleServer -Password $Password ``` ``` - -IP : 192.168.1.162 -Name : -Count : 260 - -IP : 127.0.0.1 -Name : localhost -Count : 21 +(no output) ``` ### Get-PiHoleStatsUpstream @@ -585,9 +647,9 @@ Get-PiHoleStatsUpstream -PiHoleServer $PiHoleServer -Password $Password ``` -TotalQueries : 281 -ForwardedQueries : 37 -Upstreams : {@{Ip=blocklist; Name=blocklist; Port=-1; Count=84; ResponseTime=0; Variance=0}, @{Ip=cache; Name=cache; Port=-1; Count=160; ResponseTime=0; Variance=0}, @{Ip=8.8.8.8; Name=dns.google; Port=53; Count=37; ResponseTime=0.024111141761144; Variance=0.00213313327521311}} +TotalQueries : 0 +ForwardedQueries : 0 +Upstreams : {@{Ip=blocklist; Name=blocklist; Port=-1; Count=0; ResponseTime=0; Variance=0}, @{Ip=cache; Name=cache; Port=-1; Count=0; ResponseTime=0; Variance=0}} ``` ### Get-PiHoleStatsQuerySuggestions @@ -598,10 +660,10 @@ Get-PiHoleStatsQuerySuggestions -PiHoleServer $PiHoleServer -Password $Password ``` -Domain : {wikipedia.org, bbc.com, python.org, example.com…} -ClientIp : {192.168.1.162, 127.0.0.1} -ClientName : {localhost} -Upstream : {blocklist, cache, 8.8.8.8#53 (dns.google), permitted} +Domain : {} +ClientIp : {} +ClientName : {} +Upstream : {blocklist, cache, permitted} Type : {A, AAAA, ANY, SRV…} Status : {UNKNOWN, GRAVITY, FORWARDED, CACHE…} Reply : {UNKNOWN, NODATA, NXDOMAIN, CNAME…} @@ -618,10 +680,10 @@ Get-PiHoleStatsDatabaseSummary -PiHoleServer $PiHoleServer -Password $Password ``` -SumQueries : 8 +SumQueries : 0 SumBlocked : 0 PercentBlocked : 0 -TotalClients : 1 +TotalClients : 0 ``` ### Get-PiHoleStatsDatabaseQueryType @@ -647,7 +709,7 @@ Type : SRV Count : 0 Type : SOA -Count : 8 +Count : 0 _(showing 5 of 16 results)_ ``` @@ -661,9 +723,7 @@ Get-PiHoleStatsDatabaseTopDomain -PiHoleServer $PiHoleServer -Password $Password ``` ``` - -Domain : 1.0.0.127.in-addr.arpa -Count : 8 +(no output) ``` ### Get-PiHoleStatsDatabaseTopClient @@ -675,10 +735,7 @@ Get-PiHoleStatsDatabaseTopClient -PiHoleServer $PiHoleServer -Password $Password ``` ``` - -IP : 127.0.0.1 -Name : localhost -Count : 8 +(no output) ``` ### Get-PiHoleStatsDatabaseUpstream @@ -691,9 +748,9 @@ Get-PiHoleStatsDatabaseUpstream -PiHoleServer $PiHoleServer -Password $Password ``` -TotalQueries : 8 +TotalQueries : 0 ForwardedQueries : 0 -Upstreams : {@{Ip=cache; Name=cache; Port=-1; Count=8; ResponseTime=0; Variance=0}, @{Ip=blocklist; Name=blocklist; Port=-1; Count=0; ResponseTime=0; Variance=0}} +Upstreams : {@{Ip=cache; Name=cache; Port=-1; Count=0; ResponseTime=0; Variance=0}, @{Ip=blocklist; Name=blocklist; Port=-1; Count=0; ResponseTime=0; Variance=0}} ``` ## Configuration & Diagnostics @@ -727,22 +784,22 @@ Get-PiHolePadd -PiHoleServer $PiHoleServer -Password $Password CpuPercent : 0 MemoryPercent : 0 -ActiveClients : 2 +ActiveClients : 0 Blocking : enabled -Cache : @{Size=10000; Inserted=96; Evicted=0} +Cache : @{Size=10000; Inserted=0; Evicted=0} Config : @{DhcpActive=False; DhcpStart=; DhcpEnd=; DhcpIpv6=False; DnsDnssec=False; DnsDomain=lan; DnsNumUpstreams=2; DnsPort=53; DnsrevServerAactive=False; PrivacyLevel=0} -GravitySize : 499226 +GravitySize : 496171 HostModel : Raspberry Pi Zero W Rev 1.1 IFace : @{v4=; v6=} NodeName : dns3.localdomain Pid : 389 -Queries : @{Total=281; Blocked=84; PercentBlocked=29.893238067627; QueryFrequency=0} -RecentBlocked : googleadservices.com -Sensors : @{CpuTemp=36.318; HotLimit=60; Unit=C} -System : @{Uptime=629874; Memory=; Procs=70; Cpu=; Ftl=} -TopBlocked : googleadservices.com -TopClient : 192.168.1.162 -TopDomain : wikipedia.org +Queries : @{Total=0; Blocked=0; PercentBlocked=0; QueryFrequency=0} +RecentBlocked : +Sensors : @{CpuTemp=40.084; HotLimit=60; Unit=C} +System : @{Uptime=644738; Memory=; Procs=71; Cpu=; Ftl=} +TopBlocked : +TopClient : +TopDomain : Version : @{Core=; Web=; Ftl=; Docker=} ``` @@ -779,11 +836,11 @@ Get-PiHoleInfoSystem -PiHoleServer $PiHoleServer -Password $Password ``` -Uptime : 629884 +Uptime : 644757 Memory : @{Ram=; Swap=} -Procs : 70 -Cpu : @{NumProcessors=1; PercentCpu=96.8000030517578; Load=} -Ftl : @{PercentMemory=2.40430068969727; PercentCpu=96.3000030517578} +Procs : 71 +Cpu : @{NumProcessors=1; PercentCpu=100; Load=} +Ftl : @{PercentMemory=2.33196616172791; PercentCpu=99.0999984741211} ``` ### Get-PiHoleInfoFtl @@ -794,16 +851,16 @@ Get-PiHoleInfoFtl -PiHoleServer $PiHoleServer -Password $Password ``` -Database : @{Gravity=499226; Antigravity=0; Groups=3; Lists=14; Clients=0; Domains=; Regex=} +Database : @{Gravity=496171; Antigravity=0; Groups=3; Lists=14; Clients=0; Domains=; Regex=} PrivacyLevel : 0 QueryFrequency : 0 -Clients : @{Total=2; Active=2} +Clients : @{Total=0; Active=0} Pid : 389 -Uptime : 51790377.711423 -PercentMemory : 2.40430068969727 -PercentCpu : 96.3000030517578 +Uptime : 495351.278596 +PercentMemory : 2.32703423500061 +PercentCpu : 99.3000030517578 AllowDestructive : True -Dnsmasq : @{DnsCacheInserted=96; DnsCacheLiveFreed=0; DnsQueriesForwarded=58; DnsAuthAnswered=0; DnsLocalAnswered=223; DnsStaleAnswered=21; DnsUnanswered=0; DnssecMaxCryptoUse=0; DnssecMaxSigFail=0; DnssecMaxWork=0; Bootp=0; Pxe=0; DhcpAck=0; DhcpDecline=0; DhcpDiscover=0; DhcpInform=0; DhcpNak=0; DhcpOffer=0; DhcpRelease=0; DhcpRequest=0; Noanswer=0; LeasesAllocated4=0; LeasesPruned4=0; LeasesAllocated6=0; LeasesPruned6=0; TcpConnections=0; DhcpLeasequery=0; DhcpLeaseUnassigned=0; DhcpLeaseActve=0; DhcpLeaseUnknown=0} +Dnsmasq : @{DnsCacheInserted=0; DnsCacheLiveFreed=0; DnsQueriesForwarded=0; DnsAuthAnswered=0; DnsLocalAnswered=0; DnsStaleAnswered=0; DnsUnanswered=0; DnssecMaxCryptoUse=0; DnssecMaxSigFail=0; DnssecMaxWork=0; Bootp=0; Pxe=0; DhcpAck=0; DhcpDecline=0; DhcpDiscover=0; DhcpInform=0; DhcpNak=0; DhcpOffer=0; DhcpRelease=0; DhcpRequest=0; Noanswer=0; LeasesAllocated4=0; LeasesPruned4=0; LeasesAllocated6=0; LeasesPruned6=0; TcpConnections=0; DhcpLeasequery=0; DhcpLeaseUnassigned=0; DhcpLeaseActve=0; DhcpLeaseUnknown=0} ``` ### Get-PiHoleInfoSensors @@ -815,7 +872,7 @@ Get-PiHoleInfoSensors -PiHoleServer $PiHoleServer -Password $Password ``` List : {@{Name=cpu_thermal; Path=hwmon0; Source=devices/virtual/thermal/thermal_zone0; Temps=}, @{Name=rpi_volt; Path=hwmon1; Source=devices/platform/soc/soc:firmware/raspberrypi-hwmon; Temps=}} -CpuTemp : 36.856 +CpuTemp : 40.622 HotLimit : 60 Unit : C ``` @@ -832,12 +889,12 @@ Size : 13983744 Type : Regular file Mode : rw-r----- AccessTime : 6/25/2025 12:32:44 AM -ModifiedTime : 9/27/2026 4:04:00 AM -ChangeTime : 9/27/2026 4:04:00 AM +ModifiedTime : 9/27/2026 11:44:09 AM +ChangeTime : 9/27/2026 11:44:09 AM Owner : @{User=; Group=} -Queries : 281 -EarliestTimestamp : 9/26/2026 7:40:00 AM -QueriesDisk : 281 +Queries : 0 +EarliestTimestamp : 9/26/2026 11:50:00 AM +QueriesDisk : 0 EarliestTimestampDisk : SqliteVersion : 3.53.1 ``` @@ -905,7 +962,12 @@ Get-PiHoleInfoMessage -PiHoleServer $PiHoleServer -Password $Password ``` ``` -(no output) + +Id : 1 +Timestamp : 9/27/2026 11:49:11 AM +Type : LOAD +Plain : Long-term load (15min avg) larger than number of processors: 1.3 > 1 +Html : Long-term load (15min avg) larger than number of processors: 1.3 > 1
This may slow down DNS resolution and can cause bottlenecks. ``` ### Get-PiHoleInfoMessageCount @@ -916,7 +978,7 @@ Get-PiHoleInfoMessageCount -PiHoleServer $PiHoleServer -Password $Password ``` -Count : 0 +Count : 1 ``` ### Remove-PiHoleInfoMessage @@ -940,7 +1002,7 @@ Get-PiHoleLogWebserver -PiHoleServer $PiHoleServer -Password $Password ``` -Log : @{Timestamp=9/26/2026 5:21:18 PM; Message=Initializing HTTP server on ports "8089,8489s"; Priority=} +Log : @{Timestamp=9/27/2026 11:44:11 AM; Message=Initializing HTTP server on ports "8089,8489s"; Priority=} NextID : 1 Pid : 389 File : /var/log/pihole/webserver.log @@ -970,31 +1032,31 @@ Get-PiHoleHistory -PiHoleServer $PiHoleServer -Password $Password ``` -Timestamp : 9/26/2026 7:55:00 AM +Timestamp : 9/26/2026 12:05:00 PM Total : 0 Cached : 0 Blocked : 0 Forwarded : 0 -Timestamp : 9/26/2026 8:05:00 AM +Timestamp : 9/26/2026 12:15:00 PM Total : 0 Cached : 0 Blocked : 0 Forwarded : 0 -Timestamp : 9/26/2026 8:15:00 AM +Timestamp : 9/26/2026 12:25:00 PM Total : 0 Cached : 0 Blocked : 0 Forwarded : 0 -Timestamp : 9/26/2026 8:25:00 AM +Timestamp : 9/26/2026 12:35:00 PM Total : 0 Cached : 0 Blocked : 0 Forwarded : 0 -Timestamp : 9/26/2026 8:35:00 AM +Timestamp : 9/26/2026 12:45:00 PM Total : 0 Cached : 0 Blocked : 0 @@ -1011,20 +1073,20 @@ Get-PiHoleHistoryClient -PiHoleServer $PiHoleServer -Password $Password ``` -Timestamp : 9/26/2026 7:55:00 AM -Clients : {@{IP=192.168.1.162; Name=; Count=0}, @{IP=others; Name=; Count=0}} +Timestamp : 9/26/2026 12:05:00 PM +Clients : @{IP=others; Name=; Count=0} -Timestamp : 9/26/2026 8:05:00 AM -Clients : {@{IP=192.168.1.162; Name=; Count=0}, @{IP=others; Name=; Count=0}} +Timestamp : 9/26/2026 12:15:00 PM +Clients : @{IP=others; Name=; Count=0} -Timestamp : 9/26/2026 8:15:00 AM -Clients : {@{IP=192.168.1.162; Name=; Count=0}, @{IP=others; Name=; Count=0}} +Timestamp : 9/26/2026 12:25:00 PM +Clients : @{IP=others; Name=; Count=0} -Timestamp : 9/26/2026 8:25:00 AM -Clients : {@{IP=192.168.1.162; Name=; Count=0}, @{IP=others; Name=; Count=0}} +Timestamp : 9/26/2026 12:35:00 PM +Clients : @{IP=others; Name=; Count=0} -Timestamp : 9/26/2026 8:35:00 AM -Clients : {@{IP=192.168.1.162; Name=; Count=0}, @{IP=others; Name=; Count=0}} +Timestamp : 9/26/2026 12:45:00 PM +Clients : @{IP=others; Name=; Count=0} _(showing 5 of 145 results)_ ``` @@ -1037,67 +1099,154 @@ _Defaults to the last 8 hours; pass -From/-Until for a different window._ Get-PiHoleHistoryDatabase -PiHoleServer $PiHoleServer -Password $Password ``` +``` +(no output) ``` -Timestamp : 9/27/2026 12:00:00 AM -Total : 1 -Cached : 1 -Blocked : 0 -Forwarded : 0 +### Get-PiHoleHistoryDatabaseClient -Timestamp : 9/27/2026 1:00:00 AM -Total : 1 -Cached : 1 -Blocked : 0 -Forwarded : 0 +_Defaults to the last 8 hours; pass -From/-Until for a different window._ -Timestamp : 9/27/2026 2:00:00 AM -Total : 1 -Cached : 1 -Blocked : 0 -Forwarded : 0 +```powershell +Get-PiHoleHistoryDatabaseClient -PiHoleServer $PiHoleServer -Password $Password +``` -Timestamp : 9/27/2026 3:00:00 AM -Total : 1 -Cached : 1 -Blocked : 0 -Forwarded : 0 +``` +(no output) +``` -Timestamp : 9/27/2026 4:00:00 AM -Total : 1 -Cached : 1 -Blocked : 0 -Forwarded : 0 +### Get-PiHoleNetworkGateway -_(showing 5 of 8 results)_ +```powershell +Get-PiHoleNetworkGateway -PiHoleServer $PiHoleServer -Password $Password ``` -### Get-PiHoleHistoryDatabaseClient +``` -_Defaults to the last 8 hours; pass -From/-Until for a different window._ +Family : inet +Interface : wlan0 +Address : 192.168.1.1 +Local : 192.168.1.248 +``` + +### Get-PiHoleNetworkRoute ```powershell -Get-PiHoleHistoryDatabaseClient -PiHoleServer $PiHoleServer -Password $Password +Get-PiHoleNetworkRoute -PiHoleServer $PiHoleServer -Password $Password +``` + +``` + +Table : 254 +Family : inet +Protocol : boot +Scope : universe +Type : unicast +Flags : +Gateway : 192.168.1.1 +Oif : wlan0 +Dst : default + +Table : 254 +Family : inet +Protocol : kernel +Scope : link +Type : unicast +Flags : +Dst : 192.168.1.0 +Prefsrc : 192.168.1.248 +Oif : wlan0 + +Table : 255 +Family : inet +Protocol : kernel +Scope : host +Type : local +Flags : +Dst : 127.0.0.0 +Prefsrc : 127.0.0.1 +Oif : lo + +Table : 255 +Family : inet +Protocol : kernel +Scope : host +Type : local +Flags : +Dst : 127.0.0.1 +Prefsrc : 127.0.0.1 +Oif : lo + +Table : 255 +Family : inet +Protocol : kernel +Scope : link +Type : broadcast +Flags : +Dst : 127.255.255.255 +Prefsrc : 127.0.0.1 +Oif : lo + +_(showing 5 of 11 results)_ +``` + +### Get-PiHoleNetworkInterface + +```powershell +Get-PiHoleNetworkInterface -PiHoleServer $PiHoleServer -Password $Password ``` ``` -Timestamp : 9/27/2026 7:00:00 AM -Clients : @{ClientId=3; Count=1} +Name : lo +Speed : +Type : loopback +Flags : {up, loopback, running, lower_up} +State : unknown +Carrier : True +ProtoDown : False +Address : 00:00:00:00:00:00 +Broadcast : 00:00:00:00:00:00 +Stats : @{RxBytes=; TxBytes=; Bits=64} +Addresses : {@{Family=inet; Scope=host; Flags=permanent; Prefixlen=8; Address=127.0.0.1; AddressType=loopback; Local=127.0.0.1; LocalType=loopback; Label=lo; Prefered=4294967295; Valid=4294967295; Cstamp=1789883183.02; Tstamp=1789883183.02}, @{Family=inet6; Scope=host; Flags=permanent; Prefixlen=128; Address=::1; AddressType=loopback; Prefered=4294967295; Valid=4294967295; Cstamp=1789883183.02; Tstamp=1789883183.02}} -Timestamp : 9/27/2026 6:00:00 AM -Clients : @{ClientId=3; Count=1} +Name : wlan0 +Speed : +Type : ether +Flags : {up, broadcast, running, multicast…} +State : up +Carrier : True +ProtoDown : False +Address : b8:27:eb:11:a4:d5 +Broadcast : ff:ff:ff:ff:ff:ff +PermAddress : b8:27:eb:11:a4:d5 +Stats : @{RxBytes=; TxBytes=; Bits=64} +Addresses : {@{Family=inet; Scope=universe; Flags=; Prefixlen=24; Address=192.168.1.248; AddressType=private; Local=192.168.1.248; LocalType=private; Broadcast=192.168.1.255; BroadcastType=private; Label=wlan0; Prefered=61931; Valid=61931; Cstamp=1789883203.32; Tstamp=1790074734.89}, @{Family=inet6; Scope=link; Flags=permanent; Prefixlen=64; Address=fe80::ba27:ebff:fe11:a4d5; AddressType=link-local (LL); Prefered=4294967295; Valid=4294967295; Cstamp=1789883202.81; Tstamp=1789883202.81}} +``` -Timestamp : 9/27/2026 5:00:00 AM -Clients : @{ClientId=3; Count=1} +### Get-PiHoleNetworkDevice -Timestamp : 9/27/2026 4:00:00 AM -Clients : @{ClientId=3; Count=1} +_Shows up to 10 devices by default; pass -MaxDevices for more._ -Timestamp : 9/27/2026 3:00:00 AM -Clients : @{ClientId=3; Count=1} +```powershell +Get-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $Password +``` -_(showing 5 of 8 results)_ +``` +(no output) +``` + +### Remove-PiHoleNetworkDevice + +_Network devices arise from Pi-hole having genuinely seen a device and cannot be manufactured on demand, so this example shows the error for a device ID that does not exist rather than a fabricated success._ + +```powershell +Remove-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $Password -DeviceId 5 +``` + +``` + +Error : Response status code does not indicate success: 404 (Not Found). ``` ## Authentication @@ -1111,13 +1260,41 @@ Get-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Password $Password ``` Id : 0 +CurrentSession : False +Valid : True +TlsLogin : True +TlsMixed : False +LoginAt : 9/27/2026 11:50:34 AM +LastActive : 9/27/2026 11:50:34 AM +ValidUntil : 9/27/2026 12:20:34 PM +RemoteAddress : 192.168.1.162 +UserAgent : Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.26200; en-US) PowerShell/7.6.6 +XForwardedFor : +App : True +Cli : False + +Id : 1 CurrentSession : True Valid : True TlsLogin : True TlsMixed : False -LoginAt : 9/27/2026 7:43:32 AM -LastActive : 9/27/2026 7:43:36 AM -ValidUntil : 9/27/2026 8:13:36 AM +LoginAt : 9/27/2026 11:50:38 AM +LastActive : 9/27/2026 11:50:47 AM +ValidUntil : 9/27/2026 12:20:47 PM +RemoteAddress : 192.168.1.162 +UserAgent : Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.26200; en-US) PowerShell/7.6.6 +XForwardedFor : +App : True +Cli : False + +Id : 2 +CurrentSession : False +Valid : True +TlsLogin : True +TlsMixed : False +LoginAt : 9/27/2026 11:50:39 AM +LastActive : 9/27/2026 11:50:39 AM +ValidUntil : 9/27/2026 12:20:39 PM RemoteAddress : 192.168.1.162 UserAgent : Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.26200; en-US) PowerShell/7.6.6 XForwardedFor : diff --git a/tests/ClientManagement/Get-PiHoleClient.Integration.Tests.ps1 b/tests/ClientManagement/Get-PiHoleClient.Integration.Tests.ps1 new file mode 100644 index 0000000..ef4ce5f --- /dev/null +++ b/tests/ClientManagement/Get-PiHoleClient.Integration.Tests.ps1 @@ -0,0 +1,62 @@ +# 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-PiHoleClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestClient = '192.168.99.99' + + $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 client behind + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'Pester integration test client' | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + } + + It 'returns clients as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 5 | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'filters by Client' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Client | Should -Be $script:TestClient + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/ClientManagement/Get-PiHoleClientSuggestion.Integration.Tests.ps1 b/tests/ClientManagement/Get-PiHoleClientSuggestion.Integration.Tests.ps1 new file mode 100644 index 0000000..17d94ef --- /dev/null +++ b/tests/ClientManagement/Get-PiHoleClientSuggestion.Integration.Tests.ps1 @@ -0,0 +1,38 @@ +# 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-PiHoleClientSuggestion (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 'succeeds and returns without throwing, even with no suggestions available' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleClientSuggestion -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl } | Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClientSuggestion -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleClientSuggestion -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/ClientManagement/New-PiHoleClient.Integration.Tests.ps1 b/tests/ClientManagement/New-PiHoleClient.Integration.Tests.ps1 new file mode 100644 index 0000000..652b554 --- /dev/null +++ b/tests/ClientManagement/New-PiHoleClient.Integration.Tests.ps1 @@ -0,0 +1,99 @@ +# 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 'New-PiHoleClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestClient = '192.168.99.99' + $script:TestGroupName = 'PesterClientGroup' + + $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 client/group behind + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + # Ensures the test client/group are never left behind for other test files to trip over + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue | Out-Null + } + } + + It 'adds a new client and returns a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'Pester integration test client' + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Client | Should -Be $script:TestClient + $result.Groups | Should -Contain 'Default' + + # Clean up immediately so the next test starts from a known (client-absent) state + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'applies a non-default group passed via -Group' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Group $script:TestGroupName + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Groups | Should -Be @($script:TestGroupName) + $result.Groups | Should -Not -Contain 'Default' + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when -Group names a group that does not exist, without creating the client' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Group 'DefinitelyNotARealGroup' -ErrorVariable errOut -ErrorAction SilentlyContinue + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" + + $errOut | Should -Not -BeNullOrEmpty + + $remaining = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient + $remaining | Should -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.clients[0].client | Should -Be $script:TestClient + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when the client already exists' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -ErrorVariable errOut -ErrorAction SilentlyContinue + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" + + $errOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -ErrorVariable errOut -ErrorAction SilentlyContinue + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/ClientManagement/Remove-PiHoleClient.Integration.Tests.ps1 b/tests/ClientManagement/Remove-PiHoleClient.Integration.Tests.ps1 new file mode 100644 index 0000000..a30919d --- /dev/null +++ b/tests/ClientManagement/Remove-PiHoleClient.Integration.Tests.ps1 @@ -0,0 +1,67 @@ +# 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 'Remove-PiHoleClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestClient = '192.168.99.99' + + $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 client behind + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + # Defensive cleanup in case a test left the client behind + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + } + + It 'removes an existing client and returns a formatted object' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'Pester integration test client' | Out-Null + + $result = Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Client | Should -Be $script:TestClient + $result.Status | Should -Be 'Removed' + + $remaining = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient + $remaining | Should -BeNullOrEmpty + } + + It 'removes the client when RawOutput is set, even though the API returns no body' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + # A successful delete is HTTP 204 No Content, so RawOutput is expected to be empty here - + # the client actually being gone afterward is the real signal of success. + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -RawOutput $true -Confirm:$false + + $remaining = Get-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient + $remaining | Should -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/ClientManagement/Update-PiHoleClient.Integration.Tests.ps1 b/tests/ClientManagement/Update-PiHoleClient.Integration.Tests.ps1 new file mode 100644 index 0000000..d3461de --- /dev/null +++ b/tests/ClientManagement/Update-PiHoleClient.Integration.Tests.ps1 @@ -0,0 +1,109 @@ +# 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-PiHoleClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestClient = '192.168.99.99' + $script:TestGroupName = 'PesterClientGroup' + + $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 client/group behind + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue | Out-Null + } + } + + It 'updates only the comment, preserving Group' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'original comment' | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'updated comment' + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'updated comment' + $result.Groups | Should -Contain 'Default' + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'updates only Group, preserving the current comment' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'keep this comment' | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Group $script:TestGroupName + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'keep this comment' + $result.Groups | Should -Be @($script:TestGroupName) + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when -Group names a group that does not exist' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Group 'DefinitelyNotARealGroup' -ErrorVariable errOut -ErrorAction SilentlyContinue + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" + + $errOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'raw output test' -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.clients[0].comment | Should -Be 'raw output test' + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when neither Comment nor Group is specified' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Confirm:$false | Out-Null + } + + It 'errors when the client does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client '10.10.10.10' -Comment 'irrelevant' -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient | Out-Null + + $result = Update-PiHoleClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Client $script:TestClient -Comment 'irrelevant' -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/NetworkInformation/Get-PiHoleNetworkDevice.Integration.Tests.ps1 b/tests/NetworkInformation/Get-PiHoleNetworkDevice.Integration.Tests.ps1 new file mode 100644 index 0000000..3b96ca1 --- /dev/null +++ b/tests/NetworkInformation/Get-PiHoleNetworkDevice.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-PiHoleNetworkDevice (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 + } + } + + # The test server may not have tracked any devices recently, so this only confirms the call + # succeeds rather than asserting specific devices are present. + It 'succeeds and returns without throwing, even with no devices tracked' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl } | Should -Not -Throw + } + + It 'accepts -MaxDevices and -MaxAddresses without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -MaxDevices 5 -MaxAddresses 2 } | Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/NetworkInformation/Get-PiHoleNetworkGateway.Integration.Tests.ps1 b/tests/NetworkInformation/Get-PiHoleNetworkGateway.Integration.Tests.ps1 new file mode 100644 index 0000000..e5118e6 --- /dev/null +++ b/tests/NetworkInformation/Get-PiHoleNetworkGateway.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleNetworkGateway (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 gateway info as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkGateway -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts -Detailed without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleNetworkGateway -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Detailed $true } | Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkGateway -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkGateway -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/NetworkInformation/Get-PiHoleNetworkInterface.Integration.Tests.ps1 b/tests/NetworkInformation/Get-PiHoleNetworkInterface.Integration.Tests.ps1 new file mode 100644 index 0000000..65f66e6 --- /dev/null +++ b/tests/NetworkInformation/Get-PiHoleNetworkInterface.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleNetworkInterface (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 interfaces as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkInterface -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 5 | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts -Detailed without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleNetworkInterface -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Detailed $true } | Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkInterface -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkInterface -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/NetworkInformation/Get-PiHoleNetworkRoute.Integration.Tests.ps1 b/tests/NetworkInformation/Get-PiHoleNetworkRoute.Integration.Tests.ps1 new file mode 100644 index 0000000..9e7465a --- /dev/null +++ b/tests/NetworkInformation/Get-PiHoleNetworkRoute.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 (Split-Path $PSScriptRoot -Parent) 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleNetworkRoute (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 routes as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkRoute -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 5 | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts -Detailed without error' -Skip:(-not $script:ConfigAvailable) { + { Get-PiHoleNetworkRoute -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Detailed $true } | Should -Not -Throw + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkRoute -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleNetworkRoute -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/NetworkInformation/Remove-PiHoleNetworkDevice.Integration.Tests.ps1 b/tests/NetworkInformation/Remove-PiHoleNetworkDevice.Integration.Tests.ps1 new file mode 100644 index 0000000..0bae3d4 --- /dev/null +++ b/tests/NetworkInformation/Remove-PiHoleNetworkDevice.Integration.Tests.ps1 @@ -0,0 +1,38 @@ +# 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: Network devices arise from Pi-hole having genuinely seen a device on the network 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-PiHoleNetworkDevice (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 device does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Remove-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -DeviceId 999999 -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Remove-PiHoleNetworkDevice -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -DeviceId 1 -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tools/Update-ExampleOutput.ps1 b/tools/Update-ExampleOutput.ps1 index ad26ca4..0f55591 100644 --- a/tools/Update-ExampleOutput.ps1 +++ b/tools/Update-ExampleOutput.ps1 @@ -52,6 +52,7 @@ $categoryOrder = [ordered]@{ GroupManagement = 'Group Management' ListManagement = 'List Management' DomainManagement = 'Domain Management' + ClientManagement = 'Client Management' Metrics = 'Metrics' Config = 'Configuration & Diagnostics' Authentication = 'Authentication' @@ -284,6 +285,35 @@ Add-Example -Category DomainManagement -FunctionName 'Remove-PiHoleDomain' ` -Result (Remove-PiHoleDomain -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -Domain $docsDomain -Type Allow -Kind Exact -Confirm:$false) #endregion +#region ClientManagement +$docsClient = '192.168.99.99' +Invoke-Quietly { Remove-PiHoleClient -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -Client $docsClient -Confirm:$false } + +Add-Example -Category ClientManagement -FunctionName 'New-PiHoleClient' ` + -Invocation "New-PiHoleClient -PiHoleServer `$PiHoleServer -Password `$Password -Client `"$docsClient`" -Comment `"Example client`"" ` + -Result (New-PiHoleClient -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -Client $docsClient -Comment 'Example client') + +# Same settle-time reasoning as ListManagement/DomainManagement above - give the test server a +# moment before relying on the client just being created. +Start-Sleep -Seconds 3 + +Add-Example -Category ClientManagement -FunctionName 'Get-PiHoleClient' ` + -Invocation 'Get-PiHoleClient -PiHoleServer $PiHoleServer -Password $Password' ` + -Result (Get-PiHoleClient -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +Add-Example -Category ClientManagement -FunctionName 'Get-PiHoleClientSuggestion' ` + -Invocation 'Get-PiHoleClientSuggestion -PiHoleServer $PiHoleServer -Password $Password' ` + -Result (Get-PiHoleClientSuggestion -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +Add-Example -Category ClientManagement -FunctionName 'Update-PiHoleClient' ` + -Invocation "Update-PiHoleClient -PiHoleServer `$PiHoleServer -Password `$Password -Client `"$docsClient`" -Comment `"Updated comment`"" ` + -Result (Update-PiHoleClient -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -Client $docsClient -Comment 'Updated comment') + +Add-Example -Category ClientManagement -FunctionName 'Remove-PiHoleClient' ` + -Invocation "Remove-PiHoleClient -PiHoleServer `$PiHoleServer -Password `$Password -Client `"$docsClient`"" ` + -Result (Remove-PiHoleClient -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -Client $docsClient -Confirm:$false) +#endregion + #region Metrics foreach ($fn in 'Get-PiHoleStatsSummary', 'Get-PiHoleStatsRecentBlocked', 'Get-PiHoleStatsQueryType', 'Get-PiHoleStatsTopDomain', 'Get-PiHoleStatsTopClient', 'Get-PiHoleStatsUpstream', 'Get-PiHoleStatsQuerySuggestions') { Add-Example -Category Metrics -FunctionName $fn ` @@ -315,6 +345,34 @@ foreach ($fn in 'Get-PiHoleHistoryDatabase', 'Get-PiHoleHistoryDatabaseClient') } #endregion +#region NetworkInformation +# The NetworkInformation folder isn't its own README category either - same fallback-folding +# reasoning as History above. +Add-Example -Category Config -FunctionName 'Get-PiHoleNetworkGateway' ` + -Invocation 'Get-PiHoleNetworkGateway -PiHoleServer $PiHoleServer -Password $Password' ` + -Result (Get-PiHoleNetworkGateway -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +Add-Example -Category Config -FunctionName 'Get-PiHoleNetworkRoute' ` + -Invocation 'Get-PiHoleNetworkRoute -PiHoleServer $PiHoleServer -Password $Password' ` + -Result (Get-PiHoleNetworkRoute -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +Add-Example -Category Config -FunctionName 'Get-PiHoleNetworkInterface' ` + -Invocation 'Get-PiHoleNetworkInterface -PiHoleServer $PiHoleServer -Password $Password' ` + -Result (Get-PiHoleNetworkInterface -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +Add-Example -Category Config -FunctionName 'Get-PiHoleNetworkDevice' ` + -Invocation 'Get-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $Password' ` + -Note 'Shows up to 10 devices by default; pass -MaxDevices for more.' ` + -Result (Get-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl) + +$dummyDeviceId = 999999 +Remove-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $PiHoleToken -IgnoreSsl $PiHoleIgnoreSsl -DeviceId $dummyDeviceId -Confirm:$false -ErrorVariable removeDeviceError -ErrorAction SilentlyContinue | Out-Null +Add-Example -Category Config -FunctionName 'Remove-PiHoleNetworkDevice' ` + -Invocation 'Remove-PiHoleNetworkDevice -PiHoleServer $PiHoleServer -Password $Password -DeviceId 5' ` + -Note 'Network devices arise from Pi-hole having genuinely seen a device and cannot be manufactured on demand, so this example shows the error for a device ID that does not exist rather than a fabricated success.' ` + -Result $(if ($removeDeviceError) { [PSCustomObject]@{ Error = $removeDeviceError[-1].Exception.Message } }) +#endregion + #region Actions if ($IncludeDisruptive) { Add-Example -Category Actions -FunctionName 'Invoke-PiHoleFlushNetwork' ` diff --git a/tools/Update-ReadmeCommandReference.ps1 b/tools/Update-ReadmeCommandReference.ps1 index b31b5c0..9ea8337 100644 --- a/tools/Update-ReadmeCommandReference.ps1 +++ b/tools/Update-ReadmeCommandReference.ps1 @@ -24,6 +24,7 @@ $displayNames = [ordered]@{ GroupManagement = 'Group Management' ListManagement = 'List Management' DomainManagement = 'Domain Management' + ClientManagement = 'Client Management' Metrics = 'Metrics' Config = 'Configuration & Diagnostics' Padd = 'Configuration & Diagnostics' @@ -31,7 +32,7 @@ $displayNames = [ordered]@{ Teleporter = 'Configuration & Diagnostics' Authentication = 'Authentication' } -$categoryOrder = @('Actions', 'DnsControl', 'GroupManagement', 'ListManagement', 'DomainManagement', 'Metrics', 'Config', 'Authentication') +$categoryOrder = @('Actions', 'DnsControl', 'GroupManagement', 'ListManagement', 'DomainManagement', 'ClientManagement', 'Metrics', 'Config', 'Authentication') $categoryIntros = @{ Authentication = 'Session handling is automatic for every command above, but these are available for managing sessions directly:' }