diff --git a/.gitignore b/.gitignore
index d669de9..78f21ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
-notes.txt
\ No newline at end of file
+notes.txt
+tests/IntegrationConfig.local.ps1
+TestResults/
\ No newline at end of file
diff --git a/PiHoleShell/Public/Actions/Invoke-PiHoleFlushNetwork.ps1 b/PiHoleShell/Public/Actions/Invoke-PiHoleFlushNetwork.ps1
index 87c0847..e1b6a8c 100644
--- a/PiHoleShell/Public/Actions/Invoke-PiHoleFlushNetwork.ps1
+++ b/PiHoleShell/Public/Actions/Invoke-PiHoleFlushNetwork.ps1
@@ -25,8 +25,10 @@ Invoke-PiHoleFlushNetwork -PiHoleServer "http://pihole.domain.com:8080" -Passwor
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Flushes PiHole logs')]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")]
param (
- $PiHoleServer,
- $Password,
+ [Parameter(Mandatory = $true)]
+ [System.URI]$PiHoleServer,
+ [Parameter(Mandatory = $true)]
+ [string]$Password,
[bool]$IgnoreSsl = $false,
[bool]$RawOutput = $false
)
@@ -36,7 +38,7 @@ Invoke-PiHoleFlushNetwork -PiHoleServer "http://pihole.domain.com:8080" -Passwor
$Params = @{
Headers = @{sid = $($Sid) }
- Uri = "$PiHoleServer/api/action/flush/logs"
+ Uri = "$($PiHoleServer.OriginalString)/api/action/flush/network"
Method = "Post"
ContentType = "application/json"
SkipCertificateCheck = $IgnoreSsl
diff --git a/PiHoleShell/Public/Actions/Restart-PiHoleDnsService.ps1 b/PiHoleShell/Public/Actions/Restart-PiHoleDnsService.ps1
index 7ee1e4a..2257393 100644
--- a/PiHoleShell/Public/Actions/Restart-PiHoleDnsService.ps1
+++ b/PiHoleShell/Public/Actions/Restart-PiHoleDnsService.ps1
@@ -25,8 +25,10 @@ Invoke-PiHoleRestartDns -PiHoleServer "http://pihole.domain.com:8080" -Password
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Restarts PiHole DNS')]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")]
param (
- $PiHoleServer,
- $Password,
+ [Parameter(Mandatory = $true)]
+ [System.URI]$PiHoleServer,
+ [Parameter(Mandatory = $true)]
+ [string]$Password,
[bool]$IgnoreSsl = $false,
[bool]$RawOutput = $false
)
@@ -36,7 +38,7 @@ Invoke-PiHoleRestartDns -PiHoleServer "http://pihole.domain.com:8080" -Password
$Params = @{
Headers = @{sid = $($Sid) }
- Uri = "$PiHoleServer/api/action/restartdns"
+ Uri = "$($PiHoleServer.OriginalString)/api/action/restartdns"
Method = "Post"
ContentType = "application/json"
SkipCertificateCheck = $IgnoreSsl
diff --git a/README.md b/README.md
index 8104f20..da2c77e 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,170 @@
# PiHoleShell
-A PowerShell module for PiHole v6 API.
+
+[](https://www.powershellgallery.com/packages/PiHoleShell)
+[](https://www.powershellgallery.com/packages/PiHoleShell)
+[](https://github.com/mikemadeja/PiHoleShell/actions/workflows/PSScriptAnalyzer.yml)
+[](LICENSE)
+-blue)
+
+A PowerShell module for automating and scripting against the **Pi-hole v6 REST API** — DNS blocking control, allow/deny lists, groups, stats, and server actions, all from PowerShell.
+
+> This module targets Pi-hole's v6 API only. It will not work against Pi-hole v5 or earlier.
+
+## Table of Contents
+
+- [Features](#features)
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Getting an API Password](#getting-an-api-password)
+- [Quick Start](#quick-start)
+- [Command Reference](#command-reference)
+- [Testing](#testing)
+- [Contributing](#contributing)
+- [License](#license)
+
+## Features
+
+- Enable/disable DNS blocking, optionally for a set duration
+- Manage allow/deny lists and groups
+- Trigger server actions: flush network table, restart DNS, update gravity
+- Pull stats, summaries, and diagnostic info
+- Every function authenticates and closes its own session automatically — no manual login/logout calls needed
+
+## Requirements
+
+- **PowerShell 7.0+ (Core edition)** — the module refuses to load on Windows PowerShell 5.1 or other editions
+- A reachable Pi-hole v6 server and an API app password
## Installation
-It is recommended to install this from https://www.powershellgallery.com/packages/PiHoleShell
+Install from the [PowerShell Gallery](https://www.powershellgallery.com/packages/PiHoleShell):
+
+```powershell
+Install-Module -Name PiHoleShell -Scope CurrentUser
+Import-Module -Name PiHoleShell
+```
-## Contributions
+## Getting an API Password
-I am in the beginning stages of developing this. I am open to suggestions or contributors and am learning as I go. I hope this will bring some value to people :-).
+1. Log into your Pi-hole web interface, then go to **Web Interface / API** settings and select **Configure app password**.
-## How to use
+
-Generate an app password by logging into your PiHole server.
+2. Copy the generated password, then click **Enable new app password**.
-Click Web Interface / Api
+
-Click Configure app password
+Keep this password secret — anyone with it has full API access to your Pi-hole.
-
+## Quick Start
-Copy your password, then click Enable new app password.
+```powershell
+$PiHoleServer = "https://pihole.example.com:8489"
+$Password = ""
-
+# Check whether blocking is currently enabled
+Get-PiHoleDnsBlockingStatus -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl:$true
+# Disable blocking for 5 minutes, then it re-enables automatically
+Set-PiHoleDnsBlocking -PiHoleServer $PiHoleServer -Password $Password -Blocking False -TimeInSeconds 300 -IgnoreSsl:$true
```
-PS Install-Module -Name PiHoleShell
-PS Import-Module -Name PiHoleShell
-PS Get-PiHoleDnsBlockingStatus -PiHoleServer http://PIHOLESERVER.DOMAIN.COM -Password "APPPASSWORD" -IgnoreSsl:$true
-
-Blocking Timer
--------- -----
-enabled 0
-```
\ No newline at end of file
+
+Every function accepts the same core parameters:
+
+| Parameter | Description |
+|---|---|
+| `-PiHoleServer` | Base URL of your Pi-hole, e.g. `https://pihole.example.com:8489` |
+| `-Password` | The app password from [Getting an API Password](#getting-an-api-password) |
+| `-IgnoreSsl` | Skip TLS certificate validation (useful for self-signed certs) |
+| `-RawOutput` | Return the unmodified API response instead of a formatted object |
+
+## Command Reference
+
+Functions marked 🚧 are still under active development — signatures and output shapes may change.
+
+### Actions
+
+| Function | Description |
+|---|---|
+| `Invoke-PiHoleFlushNetwork` | Flush the network table, removing known devices and their addresses |
+| `Restart-PiHoleDnsService` | Restart the `pihole-FTL` service |
+| `Update-PiHoleActionsGravity` 🚧 | Run `pihole -g` to rebuild the gravity/adlists database |
+
+### DNS Control
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleDnsBlockingStatus` | Get current blocking status and any active timer |
+| `Set-PiHoleDnsBlocking` | Enable or disable blocking, optionally for a set duration |
+
+### Group Management
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleGroup` | List groups |
+| `New-PiHoleGroup` | Create a group |
+| `Update-PiHoleGroup` | Update an existing group |
+| `Remove-PiHoleGroup` 🚧 | Delete a group |
+
+### List Management
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleList` 🚧 | List allow/deny lists |
+| `Add-PiHoleList` 🚧 | Add a domain to an allow/deny list |
+| `Remove-PiHoleList` 🚧 | Remove lists |
+| `Search-PiHoleListDomain` | Search all lists for a domain, with optional partial matching |
+
+### Metrics
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleStatsSummary` | Overview of query, system, and FTL activity |
+| `Get-PiHoleStatsRecentBlocked` | Most recently blocked domain |
+| `Get-PiHoleStatsQueryType` | Query breakdown by DNS record type |
+| `Get-PiHoleStatsTopDomain` | Top permitted/blocked domains |
+| `Get-PiHoleStatsTopClient` | Top clients by query volume |
+
+### Configuration & Diagnostics
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleConfig` 🚧 | Read the Pi-hole configuration |
+| `Get-PiHolePadd` 🚧 | Data used to power the PADD dashboard |
+| `Get-PiHoleInfoMessage` | Pi-hole diagnosis messages |
+| `Get-PiHoleInfoHost` 🚧 | Host system information |
+
+### Authentication
+
+Session handling is automatic for every command above, but these are available for managing sessions directly:
+
+| Function | Description |
+|---|---|
+| `Get-PiHoleCurrentAuthSession` | List active API sessions |
+| `Remove-PiHoleAuthSession` | Revoke a session by ID |
+
+## Testing
+
+The module ships with two kinds of [Pester](https://pester.dev/) tests under `tests/`:
+
+- **Unit tests** (`*.Tests.ps1`) mock the API and run anywhere:
+
+ ```powershell
+ Invoke-Pester -Path .\tests -ExcludeTagFilter Integration
+ ```
+
+- **Integration tests** (`*.Integration.Tests.ps1`) run against a real Pi-hole server and are skipped automatically unless configured. To run them, copy `tests/IntegrationConfig.example.ps1` to `tests/IntegrationConfig.local.ps1` (gitignored) and fill in your server URL and app password, then run:
+
+ ```powershell
+ Invoke-Pester -Path .\tests -TagFilter Integration
+ ```
+
+ These make real changes on the target server (they flush the network table, restart DNS, and rebuild gravity) — point them at a test instance, not production, if you'd rather not disrupt it.
+
+## Contributing
+
+This project is still early and growing. Issues, suggestions, and pull requests are welcome — see the 🚧 items in the [Command Reference](#command-reference) above for functions that could use testing or polish.
+
+## License
+
+[Apache License 2.0](LICENSE)
diff --git a/azuredevops-pihole-pester-tests.yml b/azuredevops-pihole-pester-tests.yml
new file mode 100644
index 0000000..8472474
--- /dev/null
+++ b/azuredevops-pihole-pester-tests.yml
@@ -0,0 +1,69 @@
+trigger:
+ branches:
+ include:
+ - main
+ - develop
+
+pr:
+ branches:
+ include:
+ - main
+ - develop
+
+pool:
+ name: mmadeja-dt
+
+variables:
+ testResultsFile: '$(System.DefaultWorkingDirectory)/TestResults/pester.xml'
+
+steps:
+ - pwsh: |
+ $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0.0' } | Select-Object -First 1
+ if (-not $pester) {
+ Write-Host "Installing Pester 5..."
+ Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -SkipPublisherCheck -Scope CurrentUser
+ }
+ else {
+ Write-Host "Using Pester $($pester.Version)"
+ }
+ displayName: 'Ensure Pester 5 is available'
+
+ - pwsh: |
+ $configContent = @"
+ `$PiHoleServer = [uri]'$env:PIHOLE_TEST_SERVER'
+ `$PiHoleToken = '$env:PIHOLE_TEST_TOKEN'
+ `$PiHoleIgnoreSsl = `$true
+ "@
+ Set-Content -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Value $configContent -Encoding utf8
+ displayName: 'Write integration test config'
+ env:
+ PIHOLE_TEST_SERVER: $(PiHoleTestServer)
+ PIHOLE_TEST_TOKEN: $(PiHoleTestToken)
+
+ - pwsh: |
+ Import-Module Pester -MinimumVersion 5.0.0 -Force
+
+ $config = New-PesterConfiguration
+ $config.Run.Path = './tests'
+ $config.Run.Exit = $true
+ $config.Output.Verbosity = 'Detailed'
+ $config.TestResult.Enabled = $true
+ $config.TestResult.OutputFormat = 'NUnitXml'
+ $config.TestResult.OutputPath = '$(testResultsFile)'
+
+ Invoke-Pester -Configuration $config
+ displayName: 'Run Pester tests'
+ workingDirectory: '$(System.DefaultWorkingDirectory)'
+
+ - pwsh: |
+ Remove-Item -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Force -ErrorAction SilentlyContinue
+ displayName: 'Clean up integration test config'
+ condition: always()
+
+ - task: PublishTestResults@2
+ displayName: 'Publish test results'
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFormat: 'NUnit'
+ testResultsFiles: '$(testResultsFile)'
+ failTaskOnFailedTests: false
diff --git a/tests/IntegrationConfig.example.ps1 b/tests/IntegrationConfig.example.ps1
new file mode 100644
index 0000000..936e1fe
--- /dev/null
+++ b/tests/IntegrationConfig.example.ps1
@@ -0,0 +1,9 @@
+# Copy this file to 'IntegrationConfig.local.ps1' (same folder) and fill in your own
+# server details. IntegrationConfig.local.ps1 is gitignored so your token is never committed.
+#
+# These values are consumed by the *.Integration.Tests.ps1 files, which make real calls
+# against a live Pi-hole server.
+
+$PiHoleServer = [uri]'https://pihole.example.com:8489'
+$PiHoleToken = 'your-api-token-here'
+$PiHoleIgnoreSsl = $true
diff --git a/tests/Invoke-PiHoleFlushNetwork.Integration.Tests.ps1 b/tests/Invoke-PiHoleFlushNetwork.Integration.Tests.ps1
new file mode 100644
index 0000000..16ff84e
--- /dev/null
+++ b/tests/Invoke-PiHoleFlushNetwork.Integration.Tests.ps1
@@ -0,0 +1,43 @@
+# Requires -Module Pester
+#
+# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1
+# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically
+# if that file is missing.
+
+# Config availability must be known at discovery time so the -Skip parameter on each It block
+# (evaluated during discovery, before BeforeAll runs) sees the correct value.
+$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1')
+
+Describe 'Invoke-PiHoleFlushNetwork (Integration)' -Tag 'Integration' {
+ BeforeAll {
+ Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force
+
+ # Recomputed here (not read from the discovery-time $script:ConfigAvailable above) because
+ # Pester runs discovery and run in separate scopes, so BeforeAll cannot see that value.
+ $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1'
+ if (Test-Path $configPath) {
+ . $configPath
+ $script:PiHoleServer = $PiHoleServer
+ $script:PiHoleToken = $PiHoleToken
+ $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl
+ }
+ }
+
+ It 'flushes the network table and returns a formatted status' -Skip:(-not $script:ConfigAvailable) {
+ $result = Invoke-PiHoleFlushNetwork -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Status | Should -Be 'Flushed'
+ }
+
+ It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) {
+ { Invoke-PiHoleFlushNetwork -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true } |
+ Should -Not -Throw
+ }
+
+ It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) {
+ $result = Invoke-PiHoleFlushNetwork -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue
+
+ $errOut | Should -Not -BeNullOrEmpty
+ }
+}
diff --git a/tests/Restart-PiHoleDnsService.Integration.Tests.ps1 b/tests/Restart-PiHoleDnsService.Integration.Tests.ps1
new file mode 100644
index 0000000..abeaad9
--- /dev/null
+++ b/tests/Restart-PiHoleDnsService.Integration.Tests.ps1
@@ -0,0 +1,46 @@
+# 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: this actually restarts the pihole-FTL service on the target server, causing a brief
+# DNS resolution interruption on that server.
+
+# Config availability must be known at discovery time so the -Skip parameter on each It block
+# (evaluated during discovery, before BeforeAll runs) sees the correct value.
+$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1')
+
+Describe 'Restart-PiHoleDnsService (Integration)' -Tag 'Integration' {
+ BeforeAll {
+ Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force
+
+ # Recomputed here (not read from the discovery-time $script:ConfigAvailable above) because
+ # Pester runs discovery and run in separate scopes, so BeforeAll cannot see that value.
+ $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1'
+ if (Test-Path $configPath) {
+ . $configPath
+ $script:PiHoleServer = $PiHoleServer
+ $script:PiHoleToken = $PiHoleToken
+ $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl
+ }
+ }
+
+ It 'restarts the DNS service and returns a formatted status' -Skip:(-not $script:ConfigAvailable) {
+ $result = Restart-PiHoleDnsService -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Status | Should -Be 'Restarted'
+ }
+
+ It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) {
+ { Restart-PiHoleDnsService -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true } |
+ Should -Not -Throw
+ }
+
+ It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) {
+ $result = Restart-PiHoleDnsService -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue
+
+ $errOut | Should -Not -BeNullOrEmpty
+ }
+}
diff --git a/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1 b/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1
new file mode 100644
index 0000000..35a9fc9
--- /dev/null
+++ b/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1
@@ -0,0 +1,40 @@
+# 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: this actually runs `pihole -g` on the target server (rebuilds the gravity/adlists),
+# which can take anywhere from several seconds to a few minutes depending on adlist size.
+
+# Config availability must be known at discovery time so the -Skip parameter on each It block
+# (evaluated during discovery, before BeforeAll runs) sees the correct value.
+$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1')
+
+Describe 'Update-PiHoleActionsGravity (Integration)' -Tag 'Integration' {
+ BeforeAll {
+ Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force
+
+ # Recomputed here (not read from the discovery-time $script:ConfigAvailable above) because
+ # Pester runs discovery and run in separate scopes, so BeforeAll cannot see that value.
+ $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1'
+ if (Test-Path $configPath) {
+ . $configPath
+ $script:PiHoleServer = $PiHoleServer
+ $script:PiHoleToken = $PiHoleToken
+ $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl
+ }
+ }
+
+ It 'runs a gravity update and returns the raw API response' -Skip:(-not $script:ConfigAvailable) {
+ $result = Update-PiHoleActionsGravity -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true -Confirm:$false
+
+ $result | Should -Not -BeNullOrEmpty
+ }
+
+ It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) {
+ $result = Update-PiHoleActionsGravity -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue
+
+ $errOut | Should -Not -BeNullOrEmpty
+ }
+}