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. + +[![PowerShell Gallery Version](https://img.shields.io/powershellgallery/v/PiHoleShell?label=PowerShell%20Gallery)](https://www.powershellgallery.com/packages/PiHoleShell) +[![PowerShell Gallery Downloads](https://img.shields.io/powershellgallery/dt/PiHoleShell)](https://www.powershellgallery.com/packages/PiHoleShell) +[![CI](https://github.com/mikemadeja/PiHoleShell/actions/workflows/PSScriptAnalyzer.yml/badge.svg)](https://github.com/mikemadeja/PiHoleShell/actions/workflows/PSScriptAnalyzer.yml) +[![License: Apache 2.0](https://img.shields.io/github/license/mikemadeja/PiHoleShell)](LICENSE) +![PowerShell 7+](https://img.shields.io/badge/PowerShell-7%2B%20(Core)-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 + Pi-hole Web Interface / API settings -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 + Configure app password dialog -Click Configure app password +Keep this password secret — anyone with it has full API access to your Pi-hole. -drawing +## Quick Start -Copy your password, then click Enable new app password. +```powershell +$PiHoleServer = "https://pihole.example.com:8489" +$Password = "" -drawing +# 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 + } +}