diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml new file mode 100644 index 0000000..c375014 --- /dev/null +++ b/.github/workflows/windows-tests.yml @@ -0,0 +1,17 @@ +name: Windows QA +on: [push, pull_request] +permissions: + contents: read +jobs: + regression: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: PowerShell 5.1 syntax and regression tests + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { $tokens=$null; $errors=$null; $null=[Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$errors); if ($errors.Count) { throw ($errors | Out-String) } } + Import-Module Pester -RequiredVersion 3.4.0 + $result = Invoke-Pester -Script ./tests -PassThru + if ($result.FailedCount -gt 0) { throw 'Regression tests failed.' } diff --git a/.gitignore b/.gitignore index 5a49f05..adb6a97 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ credential.dpapi *.bak *.zip .DS_Store + +region-state.json +run.lock +*password*.txt +*alerts*.txt diff --git a/Install-QbtGuard.ps1 b/Install-QbtGuard.ps1 index 2d4f6a2..1977c59 100644 --- a/Install-QbtGuard.ps1 +++ b/Install-QbtGuard.ps1 @@ -1,12 +1,13 @@ -# qBtGuard 0.1.0-preview installer. Run as the intended scheduled-task Windows user. +# qBtGuard 0.2.0 candidate installer. Run as the intended scheduled-task Windows user. [CmdletBinding()] param( [string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard'), - [switch]$NoTask + [switch]$NoTask, + [string]$TaskName = 'qBtGuard Audit' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$taskName = 'qBtGuard Audit' +$taskName = $TaskName $staging = $null $installed = $false $taskCreated = $false @@ -26,7 +27,7 @@ try { if (Test-Path -LiteralPath $destinationFull) { throw "Destination already exists: $destinationFull. No changes made." } $parent = Split-Path -Parent $destinationFull if (-not (Test-Path -LiteralPath $parent -PathType Container)) { throw "Destination parent does not exist: $parent" } - Write-Host 'qBtGuard preview: READ-ONLY. No torrent deletion or PIA changes.' + Write-Host 'qBtGuard candidate: audit mode by default; corrective actions require configuration and QA.' $url = Read-Host 'qBittorrent Web UI URL [http://127.0.0.1:8080]' if ([string]::IsNullOrWhiteSpace($url)) { $url = 'http://127.0.0.1:8080' } $uri = [uri]$url @@ -45,11 +46,9 @@ try { $null = Invoke-RestMethod -Uri ($baseUrl + '/api/v2/app/version') -WebSession $session -Headers $headers -TimeoutSec 15 -ErrorAction Stop $null = Invoke-RestMethod -Uri ($baseUrl + '/api/v2/torrents/info') -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop Write-Host 'Authentication and read-only API access verified.' - $config = [ordered]@{ - QbtUrl=$baseUrl; QbtUser=$user; BlockNoExtension=$true; VerifyMkvSignatures=$true - BlockedExtensions=@('.exe','.scr','.com','.bat','.cmd','.msi','.msp','.ps1','.psm1','.vbs','.vbe','.js','.jse','.wsf','.wsh','.hta','.cpl','.pif','.lnk','.jar','.zipx') - AuditOnly=$true; IntervalMinutes=10 - } + $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.example.json') -Raw | ConvertFrom-Json + $config.QbtUrl = $baseUrl; $config.QbtUser = $user + $config.AuditOnly = $true $staging = Join-Path $parent ('.qBtGuard-staging-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $staging -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $source -Destination (Join-Path $staging 'QbtGuard.ps1') -ErrorAction Stop @@ -65,7 +64,7 @@ try { $trigger = New-ScheduledTaskTrigger -Daily -At (Get-Date).AddMinutes(2) $trigger.Repetition = New-CimInstance -Namespace 'Root/Microsoft/Windows/TaskScheduler' -ClassName 'MSFT_TaskRepetitionPattern' -ClientOnly -Property @{ Interval = 'PT10M'; Duration = 'P1D'; StopAtDurationEnd = $false } $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited - $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 9) Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description 'qBtGuard read-only audit; runs while user is logged on' -ErrorAction Stop | Out-Null $taskCreated = $true } @@ -84,3 +83,4 @@ try { try { Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} } } + diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..d784a4f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,20 @@ +# Consolidation and enforcement QA + +This procedure is for anyone replacing older qBittorrent/PIA scripts. The pilot installation completed a manual cutover; the installer itself never modifies or disables other installations. + +1. Inventory all tasks and startup launchers targeting qBittorrent or PIA. Record their accounts, paths, intervals, current configuration and capabilities. Back up task XML, scripts, credentials and persistent state privately. +2. Install this candidate into a new directory with `-NoTask`. Retain the old working installation. Configure the candidate for the actual machine and VPN adapter; keep audit mode enabled. Do not reuse another Windows user's DPAPI credential. +3. Run Windows syntax and mocked regression tests. Run a live audit and confirm API authentication, interface binding, forwarded-port discovery and MKV coverage. Compare results to the existing script. Concurrent old automation can remove findings before an audit sees them. +4. On an isolated test instance, exercise each enforced action independently: prohibited extension, extensionless file, invalid header, unreadable/missing MKV, healthy restart, blocked restart, port change, reconnecting VPN, explicit forwarding failure, recovery timeout and corrupt cooldown file. Use disposable torrent data. Verify no mutation in audit mode and no unintended deletion on failures. +5. Confirm the intended qBittorrent profile and adapter names agree before enabling restart. This candidate supports the 4.3.6 `Connection\Interface` profile key; other profile layouts fail closed and need an explicit implementation/test update. +6. For cutover, first disable the verified old tasks and wait for all their active runs to finish. Keep backups and do not delete the old installation. Enable the desired candidate action flags and enforcement mode, manually verify a cycle, then register exactly one task using the candidate launcher and intended Windows account. The stock installer refuses existing destinations/tasks; task replacement must be performed separately after review. +7. Verify the hidden launcher propagates a failure exit code, the task succeeds every ten minutes, and log summaries show actual coverage. Confirm kill-switch and qBittorrent binding behavior while PIA is disconnected. +8. For rollback, disable and stop the new task/run before re-enabling the old task. Restore old configuration/state if altered. Deleted torrent data is irreversible through this script; recover from independent backups if available. + +Keep audit mode on and existing production protection in place until the candidate clears QA. The old deletion script may remain enforced during read-only comparison; never leave both enforcement engines scheduled after cutover. + +## Retiring the old installation + +After a successful observation period, export the current replacement task and preserve the old task XML, scripts, configuration, DPAPI files and state privately. Confirm the replacement task action points to the installation being retained. Remove only verified, disabled legacy task definitions; archive their script directories instead of deleting backups. Search task actions and startup launchers again for references to the archived paths. Never move the active installation without updating and verifying its task action. A directory with QA in its name can remain the production installation; renaming it is not required. + +Keep rollback data tied to the original Windows account and machine. Do not publish credentials, personal configuration, logs or task exports to GitHub. diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..cba0f1f --- /dev/null +++ b/QA.md @@ -0,0 +1,40 @@ +# qBtGuard 0.2.0 pilot validation + +## Tested implementation + +Engine commit: `f9fc69e96df5a8fb2137c1a1a2e75e1bb138f87f`. +Environment: Windows, Windows PowerShell 5.1, Pester 3.4.0, qBittorrent 4.3.6 and local PIA/WireGuard integration. +No addresses, account names, credentials, personal torrent names or raw logs are included in this report. + +## Evidence + +| Check | Observed result | +|---|---| +| Windows CI syntax and regression suite | 84 passed; 0 failed/skipped/pending | +| Pilot regression suite | 84 passed; 0 failed/skipped/pending | +| Audit action gate | Mocked regressions plus scoped live audits proposed actions without deletion | +| VPN profile/interface compatibility | Saved display name and distinct internal ID accepted; live API binding checked | +| qBittorrent restart | Missing process restarted in an isolated enforcement test after readiness and binding checks | +| PIA port synchronization | Deliberately mismatched listening port changed to the forwarded port and verified | +| VPN recovery | Revised bounded connect/retry implementation completed an unattended region recovery | +| Prohibited-extension fixture | Scoped audit flagged the file; enforcement removed torrent; both fixture data files verified absent | +| Extensionless fixture | Repeated using a verified 100% complete torrent; scoped enforcement removed torrent and both data files | +| Invalid MKV header fixture | Scoped audit inspected one completed MKV and reported invalid EBML signature; enforcement removed torrent and both data files | +| Initial whole-queue enforcement | 1,499 files, 358 MKVs inspected, 13 skipped; no findings/unreadable MKVs/removals | +| Silent scheduled execution | Replacement task returned 0 and continued on its ten-minute schedule | +| October 19 check | Latest task run 09:07 local returned 0; next run 09:17; both legacy tasks disabled | +| Quiet log observation | Successful heartbeats October 17 and 19; no errors or findings in the supplied log; latest heartbeat inspected 1,906 files and 503 MKVs, with 7 skipped | + +The pilot cutover occurred October 15, 2026, after exporting old task definitions and copying installations into a private rollback directory. Production enabled PIA recovery, port synchronization, guarded restart and prohibited/extensionless deletion. Invalid-MKV deletion remained disabled after its isolated fixture test. The active installation retained its original QA directory name. + +## Interpretation and limits + +The observation covers October 15–19, not an independently verified history of every scheduled execution. Quiet logging omits uneventful enforcement summaries, and Task Scheduler's latest result establishes only the latest run. No organic prohibited-file event was recorded during the supplied observation; deletion evidence comes from controlled fixtures. + +The first recovery test needed a manual reconnect and did not pass unattended QA. The implementation was revised and the subsequent recovery test completed without that assistance. The initial extensionless deletion attempt left files behind with unresolved location/completion issues; it was repeated from torrent creation with correct source data and verified completion. Do not count the initial attempt as a successful data-deletion test. + +MKV inspection checks the EBML signature only. It does not establish complete container integrity, valid codecs, playability or absence of malware. Skipped and unreadable MKVs are not deletion triggers. A valid file in another container renamed as MKV can be flagged. + +The original heartbeat rule writes only if the log is missing or older than 00:01 on the previous local calendar day; every-other-day heartbeats can therefore be expected. Findings, notices, actions and failures log immediately. + +Clean-machine installation, different Windows/qBittorrent/PIA versions, remote API installations and alternate profile layouts are not established by this pilot. The task requires the installing user to be logged in and the computer awake. Following a reboot, user sign-in is required. No age-based disk cleanup is included. diff --git a/QbtGuard.ps1 b/QbtGuard.ps1 index 7d1e587..0e3423c 100644 --- a/QbtGuard.ps1 +++ b/QbtGuard.ps1 @@ -1,17 +1,192 @@ -# qBtGuard 0.1.0-preview - read-only audit engine +# qBtGuard 0.2.0 candidate: unified audit and opt-in enforcement. [CmdletBinding()] -param([string]$InstallPath) +param( + [string]$InstallPath, + [ValidatePattern('^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$')] + [string]$OnlyTorrentHash +) if ([string]::IsNullOrWhiteSpace($InstallPath)) { $InstallPath = Split-Path -Parent $PSCommandPath } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$configFile = Join-Path $InstallPath 'config.json' -if (-not (Test-Path -LiteralPath $configFile)) { throw 'Missing config.json. Run Install-QbtGuard.ps1.' } -$c = Get-Content -LiteralPath $configFile -Raw | ConvertFrom-Json $log = Join-Path $InstallPath 'qbtguard.log' +$script:cycleLogged = $false function Write-GuardLog([string]$Message) { - $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message - Add-Content -LiteralPath $log -Value $line - Write-Output $line + $clean = $Message -replace '[\r\n\x00-\x1f]', ' ' + Add-Content -LiteralPath $log -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clean) + # Logging must not contaminate boolean function return values. + $script:cycleLogged = $true + Write-Host $clean +} +function Write-GuardCompletion([string]$Message, [bool]$Quiet = $false, [datetime]$Now = (Get-Date)) { + if (-not $Quiet) { Write-GuardLog $Message; return } + # Match the original local-calendar cutoff, not a rolling 24-hour timer. + $cutoff = $Now.Date.AddDays(-1).AddMinutes(1) + if (-not (Test-Path -LiteralPath $log) -or (Get-Item -LiteralPath $log).LastWriteTime -lt $cutoff) { + Write-GuardLog ("HEARTBEAT: " + $Message) + } else { + # Manual callers still see the successful summary; the disk log stays quiet. + Write-Host $Message + } +} +function Get-Setting([string]$Name, $Default) { + $property = $c.PSObject.Properties[$Name] + if ($null -eq $property) { return $Default } + return $property.Value +} +function Invoke-GuardAction([string]$Name, [scriptblock]$Action) { + if ($auditOnly) { Write-GuardLog "WOULD $Name"; return $false } + & $Action | Out-Null + Write-GuardLog "ACTION $Name" + return $true +} +function Invoke-Pia([string[]]$Arguments) { + # Use ProcessStartInfo for bounded execution and explicit exit status. + # All arguments come from fixed verbs or validated region IDs. + $si = New-Object Diagnostics.ProcessStartInfo + $si.FileName = [string](Get-Setting 'PiaCtl' '') + $si.Arguments = $Arguments -join ' ' + $si.UseShellExecute = $false; $si.CreateNoWindow = $true + $si.RedirectStandardOutput = $true; $si.RedirectStandardError = $true + $process = New-Object Diagnostics.Process + $process.StartInfo = $si + try { + if (-not $process.Start()) { throw 'PIA command did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { $process.Kill(); throw 'PIA command timed out.' } + if ($process.ExitCode -ne 0) { throw 'PIA command failed.' } + return $stdout.Result.Trim() + } finally { $process.Dispose() } +} +function Test-LocalEndpoint([uri]$Address) { + $local = @('127.0.0.1','::1') + @(Get-NetIPAddress | Select-Object -ExpandProperty IPAddress) + $resolved = @([Net.Dns]::GetHostAddresses($Address.DnsSafeHost) | ForEach-Object { $_.ToString() }) + return ($resolved.Count -gt 0 -and @($resolved | Where-Object { $_ -notin $local }).Count -eq 0) +} +function Test-VpnReady { + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -ne 'Connected') { Write-GuardLog "VPN BLOCKED: PIA connection state is $state"; return $false } + # Public exit-IP discovery is telemetry, not the local tunnel readiness gate. + # PIA can return Unknown for vpnip while Connected with a usable tunnel. + $expected = [string](Get-Setting 'VpnInterface' '') + if ([string]::IsNullOrWhiteSpace($expected)) { Write-GuardLog 'VPN BLOCKED: VpnInterface is not configured'; return $false } + $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Name -eq $expected }) + if ($adapters.Count -ne 1) { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter was not uniquely found'; return $false } + if ($adapters[0].Status -ne 'Up') { Write-GuardLog 'VPN BLOCKED: configured tunnel adapter is not Up'; return $false } + $addresses = @(Get-NetIPAddress -InterfaceIndex $adapters[0].InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' }) + if ($addresses.Count -eq 0) { Write-GuardLog 'VPN BLOCKED: no usable Preferred IPv4 address on the configured tunnel'; return $false } + return $true +} +function Get-SavedBinding { + $path = [string](Get-Setting 'QbtSettingsPath' '') + $expected = [string](Get-Setting 'VpnInterface' '') + if ([string]::IsNullOrWhiteSpace($expected) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + $ini = Get-Content -LiteralPath $path + $ids = @($ini | Where-Object { $_ -match '^Connection\\Interface=' }) + $names = @($ini | Where-Object { $_ -match '^Connection\\InterfaceName=' }) + if ($ids.Count -ne 1 -or $names.Count -gt 1) { return $null } + $id = ($ids[0] -split '=',2)[1].Trim() + if ([string]::IsNullOrWhiteSpace($id)) { return $null } + # Windows qBittorrent saves a display name separately from its internal ID. + $name = $id + if ($names.Count -eq 1) { $name = ($names[0] -split '=',2)[1].Trim() } + if ($name -ne $expected) { return $null } + return [pscustomobject]@{ Name=$name; Id=$id } +} +function Test-StartupBinding { + return ($null -ne (Get-SavedBinding)) +} +function Test-LiveBinding($Preferences) { + $saved = Get-SavedBinding + if ($null -eq $saved) { return $false } + return ([string]$Preferences.current_network_interface -eq $saved.Id) +} +function Invoke-QbtApi([string]$Endpoint, [string]$Method = 'Get', $Body = $null) { + $requestArgs = @{ Uri=($baseUrl + '/api/v2/' + $Endpoint); Method=$Method; WebSession=$session; Headers=$headers; TimeoutSec=30; ErrorAction='Stop' } + if ($null -ne $Body) { $requestArgs.Body = $Body } + Invoke-RestMethod @requestArgs +} +function Get-ForwardedPort { + $output = Invoke-Pia -Arguments @('get','portforward') + if ($output -match '^\d{1,5}$' -and [int]$output -ge 1 -and [int]$output -le 65535) { return [int]$output } + return $null +} +function Invoke-VpnRecovery { + $state = Invoke-Pia -Arguments @('get','connectionstate') + $portState = Invoke-Pia -Arguments @('get','portforward') + if ($state -eq 'Connected' -and $portState -ne 'Failed') { return } + if (-not (Get-Setting 'EnableVpnRecovery' $false)) { Write-GuardLog "NOTICE VPN recovery disabled; state=$state port=$portState"; return } + $statePath = Join-Path $InstallPath 'region-state.json' + if (Test-Path -LiteralPath $statePath) { + # A corrupt cooldown record blocks recovery rather than causing rapid rotation. + $last = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $elapsed = ([DateTimeOffset]::UtcNow - [DateTimeOffset]::Parse($last.LastAttemptUtc)).TotalMinutes + if ($elapsed -lt [int](Get-Setting 'RegionCooldownMinutes' 10)) { Write-GuardLog 'NOTICE VPN recovery cooldown active'; return } + } + $regions = @(Get-Setting 'PiaRegions' @()) + if ($regions.Count -lt 2) { throw 'Configure at least two PIA region IDs.' } + foreach ($region in $regions) { if ([string]$region -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') { throw 'Invalid PIA region ID.' } } + $current = Invoke-Pia -Arguments @('get','region') + $index = [Array]::IndexOf($regions, $current) + $next = [string]$regions[(($index + 1) % $regions.Count)] + $changed = Invoke-GuardAction "prepare PIA region recovery to $next" { + @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + # A successful CLI exit acknowledges a request, not a completed state change. + $before = Invoke-Pia -Arguments @('get','connectionstate') + if ($before -notin @('Disconnected','Disconnecting')) { + Invoke-Pia -Arguments @('disconnect') | Out-Null + } + $settled = $false + for ($poll = 0; $poll -lt 15; $poll++) { + if ((Invoke-Pia -Arguments @('get','connectionstate')) -eq 'Disconnected') { $settled = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $settled) { throw 'PIA did not finish disconnecting; region and connect requests withheld.' } + Invoke-Pia -Arguments @('set','region',$next) | Out-Null + } + if (-not $changed) { return } + # Retry acknowledged connect requests only while fully Disconnected. + # Never issue connect during Disconnecting or interrupt Connecting. + $connectAttempts = 0; $nextConnectPoll = 0 + for ($poll = 0; $poll -lt 24; $poll++) { + $state = Invoke-Pia -Arguments @('get','connectionstate') + if ($state -eq 'Disconnected' -and $connectAttempts -lt 4 -and $poll -ge $nextConnectPoll) { + $requested = Invoke-GuardAction 'request PIA connect' { + Invoke-Pia -Arguments @('connect') | Out-Null + } + if (-not $requested) { return } + $connectAttempts++ + $nextConnectPoll = $poll + 3 + Write-GuardLog "PIA connect request $connectAttempts issued; awaiting Connected state" + } + if ($state -eq 'Connected' -and (Invoke-Pia -Arguments @('get','region')) -eq $next -and (Test-VpnReady) -and $null -ne (Get-ForwardedPort)) { + Write-GuardLog 'VPN recovery verified' + return + } + Start-Sleep -Seconds 5 + } + throw 'VPN recovery timed out after bounded polling; no verified tunnel and forwarded port. Manual reconnect may be required.' +} + +function Ensure-QbtRunning { + $processName = [string](Get-Setting 'QbtProcessName' 'qbittorrent') + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + if (-not (Get-Setting 'EnableQbtRestart' $false)) { Write-GuardLog 'NOTICE qBittorrent absent; restart disabled'; return $false } + if (-not (Test-VpnReady) -or -not (Test-StartupBinding)) { throw 'Restart blocked: VPN readiness or saved interface binding not verified.' } + $exe = [string](Get-Setting 'QbtExe' '') + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'qBittorrent executable missing.' } + $started = Invoke-GuardAction 'restart qBittorrent' { + if (-not (Test-VpnReady)) { throw 'VPN changed before restart.' } + Start-Process -FilePath $exe -WindowStyle Minimized | Out-Null + } + if (-not $started) { return $false } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + if (Get-Process -Name $processName -ErrorAction SilentlyContinue) { return $true } + Start-Sleep -Seconds 2 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'qBittorrent process restart timed out.' } function Get-MkvIssue([string]$Path) { $stream = $null @@ -61,44 +236,124 @@ function Resolve-TorrentFile($Torrent, [string]$Name) { } return $null } -$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession -$headers = $null -$uri = $null + +$session = $null; $headers = $null; $uri = $null; $lock = $null +$loggedIn = $false +$stage = 'configuration' try { + # Exclusive lock covers manual and scheduled runs from this installation. + try { $lock = [IO.File]::Open((Join-Path $InstallPath 'run.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch [IO.IOException] { Write-Output 'qBtGuard already running or lock unavailable; cycle skipped.'; return } + $c = Get-Content -LiteralPath (Join-Path $InstallPath 'config.json') -Raw | ConvertFrom-Json + $auditOnly = Get-Setting 'AuditOnly' $true + if ($auditOnly -isnot [bool]) { throw 'AuditOnly must be a JSON boolean.' } + foreach ($name in @('EnablePia','EnableVpnRecovery','EnablePortSync','EnableQbtRestart','DeleteProhibitedTorrents','DeleteInvalidMkvTorrents','BlockNoExtension','VerifyMkvSignatures','RemoteEndpoint','QuietLogging')) { + if ((Get-Setting $name $false) -isnot [bool]) { throw "Invalid boolean setting: $name" } + } $uri = [uri]([string]$c.QbtUrl) - if ($uri.Scheme -notin @('http','https')) { throw 'Web UI URL must use http or https.' } + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Invalid Web UI URL.' } + $baseUrl = $uri.AbsoluteUri.TrimEnd('/') + $piaEnabled = Get-Setting 'EnablePia' $false + $remote = Get-Setting 'RemoteEndpoint' $false + if ($remote -and ($piaEnabled -or (Get-Setting 'EnableQbtRestart' $false))) { throw 'Remote endpoint cannot use local PIA or watchdog controls.' } + if ((Get-Setting 'EnableQbtRestart' $false) -and -not $piaEnabled) { throw 'Restart requires PIA integration.' } + if (-not $piaEnabled -and ((Get-Setting 'EnableVpnRecovery' $false) -or (Get-Setting 'EnablePortSync' $false))) { throw 'VPN recovery and port sync require PIA integration.' } + if ($piaEnabled) { + $stage = 'VPN' + if (-not (Test-LocalEndpoint $uri)) { throw 'PIA endpoint must resolve exclusively to this computer.' } + Invoke-VpnRecovery + if (-not (Test-VpnReady)) { throw 'VPN and configured interface not ready; cycle blocked.' } + if (-not (Ensure-QbtRunning)) { return } + } + $stage = 'authentication' $secure = (Get-Content -LiteralPath (Join-Path $InstallPath 'credential.dpapi') -Raw).Trim() | ConvertTo-SecureString $credential = New-Object System.Management.Automation.PSCredential([string]$c.QbtUser, $secure) - $headers = @{ Referer = ($uri.AbsoluteUri.TrimEnd('/') + '/'); Origin = $uri.GetLeftPart([UriPartial]::Authority) } - $login = Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{ username = [string]$c.QbtUser; password = $credential.GetNetworkCredential().Password } -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop - if ($login.Content.Trim() -ne 'Ok.') { throw 'qBittorrent authentication failed.' } - $torrents = @(Invoke-RestMethod -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/torrents/info') -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop | ForEach-Object { $_ }) + $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession + $headers = @{ Referer=($baseUrl + '/'); Origin=$uri.GetLeftPart([UriPartial]::Authority) } + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + try { + $login = Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/login') -Method Post -WebSession $session -Headers $headers -Body @{username=[string]$c.QbtUser; password=$credential.GetNetworkCredential().Password} -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + if ($login.Content.Trim() -ne 'Ok.') { throw 'Authentication rejected.' } + $loggedIn = $true + } catch { + if (-not $piaEnabled -or [DateTime]::UtcNow -ge $deadline) { throw } + Start-Sleep -Seconds 2 + } + } until ($loggedIn) + $stage = 'preferences' + if ($piaEnabled) { + $prefs = Invoke-QbtApi 'app/preferences' + if (-not (Test-LiveBinding $prefs)) { throw 'Live qBittorrent VPN interface binding mismatch.' } + if (Get-Setting 'EnablePortSync' $false) { + $port = Get-ForwardedPort + if ($null -ne $port -and [int]$prefs.listen_port -ne $port) { + $changed = Invoke-GuardAction "set qBittorrent listening port to $port" { + if (-not (Test-VpnReady) -or (Get-ForwardedPort) -ne $port) { throw 'VPN or port changed before synchronization.' } + Invoke-QbtApi 'app/setPreferences' 'Post' @{json=(@{listen_port=$port} | ConvertTo-Json -Compress)} | Out-Null + } + if ($changed -and [int](Invoke-QbtApi 'app/preferences').listen_port -ne $port) { throw 'Port change verification failed.' } + } elseif ($null -eq $port) { Write-GuardLog 'NOTICE no valid PIA forwarded port available' } + } + } + $stage = 'file scan' + $infoEndpoint = 'torrents/info' + if ($OnlyTorrentHash) { $infoEndpoint += '?hashes=' + $OnlyTorrentHash } + $torrents = @(Invoke-QbtApi $infoEndpoint | ForEach-Object { $_ }) + if ($OnlyTorrentHash) { + # Enforce scope locally even if a server ignores its hashes query filter. + $torrents = @($torrents | Where-Object { $null -ne $_ -and $_.PSObject.Properties['hash'] -and [string]$_.hash -eq $OnlyTorrentHash }) + Write-GuardLog ("SCOPE requested hash=$OnlyTorrentHash matched=" + $torrents.Count) + } $blocked = @($c.BlockedExtensions | ForEach-Object { ([string]$_).ToLowerInvariant() }) - $checked = 0; $findings = 0 + $checked = 0; $findings = 0; $mkvChecked = 0; $mkvSkipped = 0; $mkvUnreadable = 0; $deleted = 0 foreach ($torrent in $torrents) { - if ($null -eq $torrent -or -not $torrent.hash) { continue } - $files = @(Invoke-RestMethod -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/torrents/files?hash=' + [uri]::EscapeDataString([string]$torrent.hash)) -WebSession $session -Headers $headers -TimeoutSec 30 -ErrorAction Stop | ForEach-Object { $_ }) + if ($null -eq $torrent -or [string]$torrent.hash -notmatch '^[a-fA-F0-9]{40,64}$') { throw 'Invalid torrent identity returned by API.' } + $hash = [string]$torrent.hash + $files = @(Invoke-QbtApi ('torrents/files?hash=' + $hash) | ForEach-Object { $_ }) + $prohibited = $false; $invalidMkv = $false foreach ($file in $files) { if ($null -eq $file) { continue } $checked++ $name = [string]$file.name $ext = [IO.Path]::GetExtension($name).ToLowerInvariant() - if (($c.BlockNoExtension -and [string]::IsNullOrWhiteSpace($ext)) -or ($blocked -contains $ext)) { - $findings++ - Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}" extension="{2}"' -f $torrent.name, $name, $ext) + if (((Get-Setting 'BlockNoExtension' $true) -and [string]::IsNullOrWhiteSpace($ext)) -or $ext -in $blocked) { + $prohibited = $true; $findings++ + Write-GuardLog ('ALERT prohibited file: torrent="{0}" file="{1}"' -f $torrent.name, $name) } - if ($c.VerifyMkvSignatures -and $ext -eq '.mkv' -and [double]$torrent.progress -ge 1 -and [double]$file.progress -ge 1) { + if ((Get-Setting 'VerifyMkvSignatures' $true) -and $ext -eq '.mkv') { + if ([double]$torrent.progress -lt 1 -or [double]$file.progress -lt 1 -or $remote) { $mkvSkipped++; continue } $path = Resolve-TorrentFile $torrent $name - if (-not $path) { Write-GuardLog ('NOTICE MKV unavailable: torrent="{0}" file="{1}"' -f $torrent.name, $name); continue } + if (-not $path) { $mkvSkipped++; Write-GuardLog ('NOTICE MKV unavailable: file="{0}"' -f $name); continue } + $mkvChecked++ $issue = Get-MkvIssue $path - if ($issue) { $findings++; Write-GuardLog ('ALERT MKV signature: torrent="{0}" file="{1}" issue="{2}"' -f $torrent.name, $name, $issue) } + if ($issue) { + $findings++ + if ($issue -like 'UNREADABLE:*') { $mkvUnreadable++ } else { $invalidMkv = $true } + Write-GuardLog ('ALERT MKV signature: file="{0}" issue="{1}"' -f $name, $issue) + } + } + } + if (($prohibited -and (Get-Setting 'DeleteProhibitedTorrents' $false)) -or ($invalidMkv -and (Get-Setting 'DeleteInvalidMkvTorrents' $false))) { + $didDelete = Invoke-GuardAction "delete torrent $hash AND downloaded data" { + # Recheck existence before deletion; never call direct filesystem deletion. + $present = @(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }) + if ($present.Count -ne 1 -or [string]$present[0].hash -ne $hash) { throw 'Torrent changed or disappeared before deletion.' } + Invoke-QbtApi 'torrents/delete' 'Post' @{hashes=$hash; deleteFiles='true'} | Out-Null + if (@(Invoke-QbtApi ('torrents/info?hashes=' + $hash) | ForEach-Object { $_ }).Count -ne 0) { throw 'Torrent removal not verified.' } } + if ($didDelete) { $deleted++ } } } - Write-GuardLog ("AUDIT completed: files=$checked findings=$findings. No changes made.") -} catch { Write-GuardLog ('ERROR: audit failed (' + $_.Exception.GetType().Name + '). Check Web UI connectivity, credentials, and configuration.'); exit 1 } -finally { - if ($null -ne $session -and $null -ne $headers -and $null -ne $uri) { - try { Invoke-WebRequest -Uri ($uri.AbsoluteUri.TrimEnd('/') + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} + $mode = 'ENFORCE'; if ($auditOnly) { $mode = 'AUDIT' } + $quietCompletion = (Get-Setting 'QuietLogging' $false) -and -not $auditOnly -and -not $OnlyTorrentHash -and $findings -eq 0 -and -not $script:cycleLogged + Write-GuardCompletion "$mode completed: files=$checked findings=$findings mkvChecked=$mkvChecked mkvSkipped=$mkvSkipped mkvUnreadable=$mkvUnreadable torrentsRemoved=$deleted" $quietCompletion +} catch { + Write-GuardLog ('ERROR: cycle failed at ' + $stage + ' (' + $_.Exception.GetType().Name + '). No successful completion recorded.') + exit 1 +} finally { + if ($loggedIn) { + try { Invoke-WebRequest -Uri ($baseUrl + '/api/v2/auth/logout') -Method Post -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | Out-Null } catch {} } + if ($null -ne $lock) { $lock.Dispose() } } diff --git a/README.md b/README.md index 2810087..a6e4b59 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,82 @@ -# qBtGuard — 0.1.0 preview +# qBtGuard — 0.2.0 candidate -**Status: experimental, read-only auditor. Validated on one Windows installation; independent clean-VM testing remains outstanding.** +One Windows PowerShell 5.1 engine for torrent inspection, opt-in deletion, PIA recovery, forwarded-port synchronization, and qBittorrent restart. **Release candidate: validated on one Windows installation, with audit mode retained as the installation default.** -qBtGuard is a Windows PowerShell utility for auditing torrents managed by qBittorrent. It reports potentially dangerous file extensions, extensionless files, and completed `.mkv` files that do not begin with the EBML container signature. +## Defaults and operation -**This preview never deletes torrents or downloads, starts or stops qBittorrent, or changes PIA settings.** It is **not a VPN kill switch**. The source project's VPN watchdog, port-forwarding synchronization, region failover and automatic deletion are intentionally excluded pending safety and portability testing. +Installation starts in audit mode (`AuditOnly: true`). All corrective actions pass through a shared action gate: audit mode logs what would happen without switching VPN regions, changing ports, starting qBittorrent, or deleting torrents. Authentication/logout, logs and a temporary execution lock still operate. -## Requirements +Set `AuditOnly: false` only after QA and enable each desired action separately. Corrective-action flags default to false, including on older configurations. `DeleteProhibitedTorrents` removes the **entire offending torrent AND its downloaded data** through qBittorrent. `DeleteInvalidMkvTorrents` is separate and defaults off. An unreadable MKV alone never causes deletion. -- Windows 10/11, Windows PowerShell 5.1 and the ScheduledTasks module for scheduled operation. -- qBittorrent Web UI enabled with API v2; first compatibility target is qBittorrent 4.3.6. -- qBittorrent Web UI over HTTP or HTTPS. HTTP is supported on trusted LANs, but sends Web UI credentials and session data unencrypted. Do not expose an HTTP Web UI to the public Internet; use HTTPS or a secure tunnel for untrusted networks. -- The scheduled-task account must be able to access downloads, including network shares. +| Setting | Meaning | +|---|---| +| `QuietLogging` | Suppress uneventful unscoped enforcement summaries on disk; retain the calendar-cutoff heartbeat and diagnostic messages | +| `QbtUrl`, `QbtUser` | User-supplied Web UI connection | +| `RemoteEndpoint` | Explicit remote API mode; skips local MKV inspection and blocks local PIA/restart controls | +| `EnablePia` | Require local PIA tunnel and qBittorrent interface binding | +| `EnableVpnRecovery` | Rotate configured PIA regions after disconnection or explicit forwarding failure | +| `EnablePortSync` | Apply and verify PIA's forwarded port | +| `EnableQbtRestart` | Start a missing local process after VPN and saved profile binding checks | +| `VpnInterface` | Exact local VPN adapter name; must match the saved display name; the live qBittorrent binding is checked against its saved internal ID | +| `QbtSettingsPath` | Intended user's qBittorrent INI file; restart requires its saved interface name (or legacy alias) to match; the live API must match the saved internal ID | +| `PiaCtl`, `QbtExe`, `QbtProcessName` | Local installation paths and process name | +| `PiaRegions`, `RegionCooldownMinutes` | User-selected region IDs and persistent recovery cooldown | +| `BlockNoExtension`, `BlockedExtensions` | File-name inspection policy | +| `VerifyMkvSignatures` | Inspect completed local MKV headers | +| `DeleteProhibitedTorrents`, `DeleteInvalidMkvTorrents` | Separate opt-in deletion policies | -PIA is **not required** for this audit-only preview. +PIA integration is optional. With it enabled, the API endpoint must resolve exclusively to this machine. The tunnel must report Connected, have a Preferred, non-link-local IPv4 address on the configured Up adapter, and qBittorrent must report the configured interface binding. qBtGuard is **not a kill switch**: configure PIA's kill switch and qBittorrent's interface binding independently. It does not stop an already running qBittorrent if the tunnel fails. Recovery can take up to 120 seconds; the scheduler prevents concurrent scheduled instances and the engine locks each installation against concurrent manual runs. + +The first compatibility target is qBittorrent 4.3.6. Profile/interface naming, PIA behavior, x86/x64 paths and other versions require Windows validation. No user's addresses, credentials, logs, or preferred region list are embedded in the engine. ## Installation -1. Download and extract the repository; inspect the PowerShell scripts before running them. -2. Open Windows PowerShell as the Windows account that will run the audit. -3. From the extracted folder, run: - `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-QbtGuard.ps1` -4. Enter the Web UI address, username, and password. The password is stored with Windows DPAPI, not in the JSON configuration. -5. Test manually: - `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\qBtGuard\QbtGuard.ps1"` -6. Inspect `%LOCALAPPDATA%\qBtGuard\qbtguard.log`. +Keep the installer, engine, launcher and `config.example.json` together. Run as the Windows user who will run the task: -The `qBtGuard Audit` scheduled task repeats every 10 minutes **while the installing Windows user is logged in**. It launches `Run-QbtGuard.vbs` through `wscript.exe` (`//B //Nologo`), which runs the PowerShell audit with its console window hidden. The scheduled audit writes results to `qbtguard.log` rather than displaying a console window. The installer must be kept alongside `Run-QbtGuard.vbs` and `QbtGuard.ps1` in the extracted source directory. Run the installer with `-NoTask` to omit scheduling. The installer refuses to create a task if a task named `qBtGuard Audit` already exists; it does not replace the existing task. DPAPI credentials cannot generally be decrypted from a different user account. +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-QbtGuard.ps1 +``` -## Uninstall +The installer asks for URL/user/password and saves the password with Windows DPAPI. The default destination is `%LOCALAPPDATA%\qBtGuard`. It refuses existing destinations and existing task names. Use `-NoTask` for isolated QA; `-TaskName` permits a distinct test task. The task repeats every ten minutes while the installing user is logged on and runs hidden. Following a reboot, sign in with that user before expecting runs. Locking the session is supported; sleep and sign-out prevent normal execution. The supplied scheduler settings use Windows defaults for battery restrictions. Do not schedule multiple installations against the same qBittorrent instance. -Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Uninstall-QbtGuard.ps1` from the extracted source directory. The uninstaller removes a `qBtGuard Audit` task only when its launcher matches the specified installation directory; otherwise it refuses to remove it. The installed directory, settings and logs are retained for inspection. Downloads are never touched. +After installation edit only the installed `config.json`. Paths, interface names, and PIA regions are configuration, not repository edits. For remote Web UI use set `RemoteEndpoint: true`; deletion still acts on that remote server if explicitly enabled. Remote downloads are not mapped into local paths by this candidate. -## Security limitations +Run the installed engine manually and inspect its `qbtguard.log`. Completion entries include file findings, MKVs checked/skipped/unreadable, and torrent removals. A zero finding count with skipped MKVs does not mean every video was inspected. Header inspection accepts EBML, not full container integrity, codec validity, playable media, or absence of malware. MP4 or another legitimate format renamed `.mkv` will be flagged, so leave invalid-MKV deletion off until that policy is reviewed. No age-based disk cleanup is included. -- This is a **report-only** preview, not antivirus software or a guaranteed security boundary. -- An EBML header check does not establish that a file is playable or safe. -- MKV header checks apply only to files the API reports complete. Missing files are reported as unavailable. -- Blocked extensions are reported even if the torrent is incomplete. -- Logs can contain torrent and file names: treat logs as private. MKV file-read failures use a generic error message instead of logging raw filesystem exceptions. -- Secure your qBittorrent Web UI; do not expose it directly to the public Internet. -- The preview has **not** yet been tested on a clean Windows VM, additional qBittorrent versions, alternate PIA versions, or all UNC path arrangements. +API calls and PIA commands have timeouts. Failures log a stage and exception type rather than raw credential-bearing exceptions. Logs contain file/torrent names and must remain private. The engine rechecks torrent existence before deletion and verifies API removal; it does not independently establish all downloaded data has disappeared from disk. -## Validation completed on the initial Windows test system +## Migration and rollback -- 26 Pester 3.4.0 regression tests passed, covering mocked API failures, MKV headers, log privacy, path safety, read-only safeguards, and uninstaller task ownership. -- A clean installation using `-NoTask` authenticated successfully and completed a read-only audit of 3,638 files with zero findings. -- The VBScript launcher ran invisibly and wrote a successful audit completion entry. -- An existing scheduled task was observed running at approximately 10-minute intervals with successful completion logs. **This does not independently validate task creation by the installer.** -- A Gitleaks scan of the cloned repository history reported no detected leaks. Automated scanning does not guarantee the absence of secrets. +See [MIGRATION.md](MIGRATION.md). The candidate installer does not automatically disable any existing task or turn enforcement on. Preserve old scripts, credentials, task XML and state until the new installation has passed Windows QA. Do not copy personal logs/configuration into this repository. -## Development roadmap +Uninstall with `Uninstall-QbtGuard.ps1 -Destination ` and optional `-TaskName`. It removes only a task whose action matches that installation and retains installed files. Downloads are untouched. -- Validate PowerShell syntax and installer task-registration behavior on a clean Windows VM. -- Expand regression tests for additional edge cases. -- Verify qBittorrent 4.3.6 and newer versions separately. -- Add opt-in PIA integration only after VPN-interface binding and failure-mode tests. -- Consider deletion only with explicit opt-in, clear safeguards, and recovery procedures. +## QA status -## Privacy +Windows PowerShell 5.1 syntax checks and 84 Pester 3.4.0 tests passed in CI and on the pilot machine. Live fixture and control tests passed, and the single replacement task was observed operating from October 15 through October 19, 2026. The latest reported task result was successful; the available log contained successful heartbeats and no recorded errors. Quiet logging does not provide a complete history of every scheduled run. See [QA.md](QA.md) for evidence and remaining compatibility limits, and [tests/README.md](tests/README.md) for the test procedure. -No personal configuration, passwords, private logs, hostnames, local addresses, or paths from the original installation belong in this repository. The `.gitignore` excludes local credentials, configuration, and logs. Review all commits before making this repository public. +HTTP Web UI credentials are unencrypted in transit; use HTTPS or a trusted local connection. Do not expose the Web UI directly to the Internet. DPAPI files are tied to the installing Windows account and machine. -## Contributing +## License -Please provide reproducible bug reports with sanitized logs and version numbers. Do not post passwords, private IP addresses, share paths, or sensitive torrent filenames. +[MIT](LICENSE). -## License +### PIA public-IP discovery + +The VPN readiness gate uses PIA's Connected state and the configured Up adapter with a usable Preferred local IPv4 address. The saved and live qBittorrent interface bindings remain separately checked. Public exit-IP discovery (`piactl get vpnip`) is not a gate: a live integration test observed `Unknown` alongside Connected, a usable WireGuard tunnel and an assigned forwarded port. This change avoids blocking port synchronization on unavailable address telemetry; it does not independently prove end-to-end Internet reachability. PIA CLI failures and unavailable local tunnel checks still fail the cycle. VPN BLOCKED log messages identify unsuccessful readiness checks. + +### Asynchronous PIA recovery + +The recovery action waits up to 30 seconds for Disconnected before changing region. It then polls for a connected tunnel in the selected region and an assigned forwarded port. Connect requests are sent only from Disconnected, at least 15 seconds apart, with a maximum of four requests during 24 five-second polling intervals. A CLI success exit is treated as request acknowledgment, not proof of connection. Native-command overhead adds to the polling duration. Disconnect settling timeouts withhold the region/connect commands, and failures remain visible in the log. Audit mode performs no disconnects, region changes, connect requests or recovery waits. + +The first live recovery test required a manual reconnect; the revised implementation subsequently passed an unattended live recovery test. Future live testing must have an independent local reconnect fallback prepared before disconnecting the VPN; the kill switch can block the chat connection. Keep production protection restored between tests. + +### One-torrent inspection and enforcement + +Use `QbtGuard.ps1 -OnlyTorrentHash ` to restrict file inspection and torrent deletion to one 40- or 64-character hexadecimal hash. The server query is filtered and the returned torrent list is filtered again locally. An absent target produces a scope message with zero matches and no torrent deletion. This option does not enable enforcement or override the configuration. VPN recovery, restart and port synchronization policies remain independent: disable those policies when doing a deletion-only fixture test. Omit the option for normal whole-queue operation. + +### Quiet production logging and live QA + +`QuietLogging` defaults to false. When enabled, an unscoped enforcement cycle with no findings or other log messages prints its completion summary to the console without appending it to disk. It writes a `HEARTBEAT` with the summary when the log is missing or was last modified before 00:01 on the previous local calendar day. This inherited rule can yield a heartbeat every other day; it is not a once-per-day timer. Audit runs, scoped QA, findings, actions, notices and failures retain normal logging. The heartbeat runs only after a successful cycle; it is not proof that skipped MKVs were inspected. -qBtGuard is released under the [MIT License](LICENSE). You may use, copy, modify, and redistribute it, including commercially, provided the copyright and license notices are retained. +Live QA on the initial Windows/qBittorrent 4.3.6 system demonstrated guarded restart, forwarded-port synchronization, unattended recovery with the revised retry logic, and scoped deletion of prohibited-extension, extensionless and invalid-header MKV fixture torrents. Each successful deletion fixture also had both downloaded files verified absent. The first recovery attempt required manual assistance; the revised recovery test completed without it. A first extensionless fixture had unresolved location/completion issues; the repeated test used a verified 100% complete fixture. These observations do not establish clean-machine portability or full MKV integrity checking. The pilot was subsequently cut over to one enforcement task; the previous tasks were disabled and rollback files retained. See [QA.md](QA.md). diff --git a/Run-QbtGuard.vbs b/Run-QbtGuard.vbs index 167af5c..2a7538f 100644 --- a/Run-QbtGuard.vbs +++ b/Run-QbtGuard.vbs @@ -1,7 +1,8 @@ -Option Explicit -Dim shell, fso, scriptPath, command -Set shell = CreateObject("WScript.Shell") -Set fso = CreateObject("Scripting.FileSystemObject") -scriptPath = fso.BuildPath(fso.GetParentFolderName(WScript.ScriptFullName), "QbtGuard.ps1") -command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & Chr(34) & scriptPath & Chr(34) -WScript.Quit shell.Run(command, 0, True) +Option Explicit +Dim shell, fso, scriptPath, command +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") +scriptPath = fso.BuildPath(fso.GetParentFolderName(WScript.ScriptFullName), "QbtGuard.ps1") +command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & Chr(34) & scriptPath & Chr(34) +WScript.Quit shell.Run(command, 0, True) + diff --git a/Uninstall-QbtGuard.ps1 b/Uninstall-QbtGuard.ps1 index 7d34664..583240d 100644 --- a/Uninstall-QbtGuard.ps1 +++ b/Uninstall-QbtGuard.ps1 @@ -1,9 +1,9 @@ # qBtGuard 0.1.0-preview uninstaller. Retains installed files and logs. [CmdletBinding()] -param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard')) +param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'qBtGuard'), [string]$TaskName = 'qBtGuard Audit') Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$taskName = 'qBtGuard Audit' +$taskName = $TaskName $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if ($task) { $expectedLauncher = Join-Path ([IO.Path]::GetFullPath($Destination)) 'Run-QbtGuard.vbs' @@ -19,3 +19,4 @@ if ($task) { } if (Test-Path -LiteralPath $Destination) { Write-Host "Installation directory retained at $Destination for manual inspection/removal." } Write-Host 'Torrents and downloads untouched.' + diff --git a/config.example.json b/config.example.json index fcfafc8..312ccf5 100644 --- a/config.example.json +++ b/config.example.json @@ -27,5 +27,23 @@ ".zipx" ], "AuditOnly": true, - "IntervalMinutes": 10 + "QuietLogging": false, + "IntervalMinutes": 10, + "EnablePia": false, + "EnableVpnRecovery": false, + "EnablePortSync": false, + "EnableQbtRestart": false, + "DeleteProhibitedTorrents": false, + "DeleteInvalidMkvTorrents": false, + "RemoteEndpoint": false, + "PiaCtl": "C:\\Program Files\\Private Internet Access\\piactl.exe", + "VpnInterface": "", + "QbtExe": "C:\\Program Files\\qBittorrent\\qbittorrent.exe", + "QbtProcessName": "qbittorrent", + "QbtSettingsPath": "", + "PiaRegions": [ + "bulgaria", + "bolivia" + ], + "RegionCooldownMinutes": 10 } diff --git a/tests/QbtGuard.Api.Tests.ps1 b/tests/QbtGuard.Api.Tests.ps1 index 495634d..dd3fcd1 100644 --- a/tests/QbtGuard.Api.Tests.ps1 +++ b/tests/QbtGuard.Api.Tests.ps1 @@ -23,9 +23,9 @@ Describe 'qBtGuard mocked API behavior' { $ast = [System.Management.Automation.Language.Parser]::ParseFile($enginePath, [ref]$tokens, [ref]$errors) $errors.Count | Should Be 0 $requests = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Invoke-WebRequest','Invoke-RestMethod') }, $true)) - $requests.Count | Should Be 4 + $requests.Count | Should Be 3 foreach ($request in $requests) { - $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+' + $request.CommandElements.Extent.Text -join ' ' | Should Match '-TimeoutSec\s+\d+|@requestArgs' } } @@ -41,7 +41,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { [pscustomobject]@{ Content='Fails.' } } Mock Invoke-RestMethod { throw 'Torrent API should not be called after failed login' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It } @@ -49,7 +49,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { throw 'Simulated connection refused' } Mock Invoke-RestMethod { throw 'Torrent API should not be called' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It } @@ -59,7 +59,7 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-RestMethod { throw 'Should not be reached' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' $contents = Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw - $contents | Should Match 'ERROR: audit failed' + $contents | Should Match 'ERROR: cycle failed' $contents | Should Not Match $secret $contents | Should Not Match 'STACK:' } @@ -68,7 +68,114 @@ Describe 'qBtGuard mocked API behavior' { Mock Invoke-WebRequest { [pscustomobject]@{ Content='Ok.' } } Mock Invoke-RestMethod { throw 'Simulated torrent API timeout' } { & $engineScript -InstallPath $install } | Should Throw 'qBtGuard test: audit aborted' - (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: audit failed' + (Get-Content -LiteralPath (Join-Path $install 'qbtguard.log') -Raw) | Should Match 'ERROR: cycle failed' Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It } } + + +Describe 'Unified engine deletion and remote controls' { + BeforeEach { + $install=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $install | Out-Null + $config=@{QbtUrl='http://127.0.0.1:8080';QbtUser='test';AuditOnly=$true;BlockedExtensions=@('.exe');BlockNoExtension=$true;VerifyMkvSignatures=$true;DeleteProhibitedTorrents=$true} + ConvertTo-SecureString 'test-password' -AsPlainText -Force | ConvertFrom-SecureString | Set-Content (Join-Path $install 'credential.dpapi') + $hash='a' * 40 + $torrent=[pscustomobject]@{hash=$hash;name='fixture';progress=0;save_path=$install;content_path=''} + Mock Invoke-WebRequest { [pscustomobject]@{Content='Ok.'} } + } + It 'reports a prohibited file without deletion in audit mode' { + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { if ($Uri -match 'torrents/files') { @([pscustomobject]@{name='bad.exe';progress=0}) } elseif ($Uri -match 'torrents/info') { @($torrent) } } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'WOULD delete torrent' + } + It 'deletes only the offending hash with downloaded data and verifies removal' { + $config.AuditOnly=$false + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + $script:removed=$false + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/delete') { $script:removed=$true; return } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='bad.exe';progress=0}) } + if ($Uri -match 'torrents/info' -and -not $script:removed) { return @($torrent) } + return @() + } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' -and $Body.hashes -eq $hash -and $Body.deleteFiles -eq 'true' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'torrentsRemoved=1' + } + It 'does not delete when the deletion policy is disabled' { + $config.AuditOnly=$false; $config.DeleteProhibitedTorrents=$false + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { if ($Uri -match 'torrents/files') { @([pscustomobject]@{name='bad.exe';progress=0}) } elseif ($Uri -match 'torrents/info') { @($torrent) } } + & $engineScript -InstallPath $install + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' } + } + It 'blocks local VPN control when configured for a remote endpoint' { + $config.RemoteEndpoint=$true; $config.EnablePia=$true + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { throw 'Must not contact API' } + { & $engineScript -InstallPath $install } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } + It 'rejects a string in place of the audit boolean' { + $config.AuditOnly='false' + $config | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + Mock Invoke-RestMethod { throw 'Must not contact API' } + { & $engineScript -InstallPath $install } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } +} + +Describe 'Single torrent scope' { + BeforeEach { + $install=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $install | Out-Null + $targetHash='a' * 40; $otherHash='b' * 40 + $target=[pscustomobject]@{hash=$targetHash;name='target';progress=0;save_path=$install;content_path=''} + $other=[pscustomobject]@{hash=$otherHash;name='unrelated';progress=0;save_path=$install;content_path=''} + @{QbtUrl='http://127.0.0.1:8080';QbtUser='test';AuditOnly=$true;BlockedExtensions=@('.exe');BlockNoExtension=$true;VerifyMkvSignatures=$false;DeleteProhibitedTorrents=$true} | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + ConvertTo-SecureString 'test-password' -AsPlainText -Force | ConvertFrom-SecureString | Set-Content (Join-Path $install 'credential.dpapi') + Mock Invoke-WebRequest { [pscustomobject]@{Content='Ok.'} } + } + It 'ignores unrelated torrents even when the API ignores the hash filter' { + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/info') { return @($target,$other) } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='fixture.exe';progress=0}) } + } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$targetHash) } + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$otherHash) } + } + It 'does not inspect or delete any torrent when the requested hash is absent' { + Mock Invoke-RestMethod { if ($Uri -match 'torrents/info') { return @($other) }; throw 'Must not scan files' } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/(files|delete)' } + Get-Content (Join-Path $install 'qbtguard.log') -Raw | Should Match 'matched=0' + } + It 'rejects malformed hashes before contacting the API' { + Mock Invoke-RestMethod {} + { & $engineScript -InstallPath $install -OnlyTorrentHash 'invalid;hash' } | Should Throw + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It + } + It 'enforces deletion only for the requested torrent' { + $cfg=Get-Content (Join-Path $install 'config.json') -Raw | ConvertFrom-Json + $cfg.AuditOnly=$false + $cfg | ConvertTo-Json | Set-Content (Join-Path $install 'config.json') + $script:scopeRemoved=$false; $script:scopeInfoReads=0 + Mock Invoke-RestMethod { + if ($Uri -match 'torrents/info') { + $script:scopeInfoReads++ + if ($script:scopeInfoReads -eq 1) { return @($target,$other) } + if (-not $script:scopeRemoved) { return @($target) } + return @() + } + if ($Uri -match 'torrents/files') { return @([pscustomobject]@{name='fixture.exe';progress=0}) } + if ($Uri -match 'torrents/delete') { $script:scopeRemoved=$true } + } + & $engineScript -InstallPath $install -OnlyTorrentHash $targetHash + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -match 'torrents/delete' -and $Body.hashes -eq $targetHash -and $Body.deleteFiles -eq 'true' } + Assert-MockCalled Invoke-RestMethod -Times 0 -Exactly -Scope It -ParameterFilter { $Uri -match ('torrents/files\?hash='+$otherHash) } + } +} diff --git a/tests/QbtGuard.Control.Tests.ps1 b/tests/QbtGuard.Control.Tests.ps1 new file mode 100644 index 0000000..9eb2f17 --- /dev/null +++ b/tests/QbtGuard.Control.Tests.ps1 @@ -0,0 +1,212 @@ +# Mocked controls: no VPN, process, or torrent changes are performed. +$engine = Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +foreach ($name in @('Get-Setting','Invoke-GuardAction','Test-LocalEndpoint','Test-VpnReady','Get-SavedBinding','Test-StartupBinding','Test-LiveBinding','Get-ForwardedPort','Invoke-VpnRecovery','Ensure-QbtRunning')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) +} +# Stubs exist only so Pester can mock engine-owned functions without running the engine. +function Write-GuardLog { param($Message) } +function Invoke-Pia { param($Arguments) } +Describe 'Global action gate' { + It 'suppresses every action in audit mode' { + $auditOnly=$true; $script:executed=$false + Invoke-GuardAction 'test action' { $script:executed=$true } | Should Be $false + $script:executed | Should Be $false + } + It 'executes an action in enforcement mode' { + $auditOnly=$false; $script:executed=$false + Invoke-GuardAction 'test action' { $script:executed=$true } | Should Be $true + $script:executed | Should Be $true + } + It 'does not report success when an action fails' { + $auditOnly=$false + { Invoke-GuardAction 'test action' { throw 'failed' } } | Should Throw + } +} +Describe 'Forwarded port validation' { + It 'accepts a valid port' { Mock Invoke-Pia { '12345' }; Get-ForwardedPort | Should Be 12345 } + It 'rejects Failed' { Mock Invoke-Pia { 'Failed' }; Get-ForwardedPort | Should BeNullOrEmpty } + It 'rejects zero' { Mock Invoke-Pia { '0' }; Get-ForwardedPort | Should BeNullOrEmpty } + It 'rejects an out of range port' { Mock Invoke-Pia { '65536' }; Get-ForwardedPort | Should BeNullOrEmpty } +} +Describe 'qBittorrent restart safeguards' { + BeforeEach { + $c=[pscustomobject]@{EnableQbtRestart=$true;QbtProcessName='qbittorrent';QbtExe='test.exe'} + $auditOnly=$false + Mock Get-Process { $null } + Mock Start-Process { } + Mock Test-VpnReady { $true } + Mock Test-StartupBinding { $true } + Mock Test-Path { $true } + } + It 'does not restart when the VPN is unhealthy' { + Mock Test-VpnReady { $false } + { Ensure-QbtRunning } | Should Throw + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'does not restart when saved binding cannot be verified' { + Mock Test-StartupBinding { $false } + { Ensure-QbtRunning } | Should Throw + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'does not restart in audit mode' { + $auditOnly=$true + Ensure-QbtRunning | Should Be $false + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'respects disabled restart' { + $c.EnableQbtRestart=$false + Ensure-QbtRunning | Should Be $false + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } + It 'leaves an existing process running' { + Mock Get-Process { [pscustomobject]@{Id=123} } + Ensure-QbtRunning | Should Be $true + Assert-MockCalled Start-Process -Times 0 -Exactly -Scope It + } +} +Describe 'VPN recovery audit and cooldown' { + BeforeEach { + $InstallPath=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $InstallPath | Out-Null + $c=[pscustomobject]@{EnableVpnRecovery=$true;PiaRegions=@('region-a','region-b');RegionCooldownMinutes=10} + $auditOnly=$true + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Reconnecting' } elseif ($Arguments[1] -eq 'portforward') { 'Failed' } elseif ($Arguments[1] -eq 'region') { 'region-a' } } + } + It 'plans recovery without changing PIA or cooldown state' { + Invoke-VpnRecovery + Test-Path (Join-Path $InstallPath 'region-state.json') | Should Be $false + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'honors an existing recovery cooldown' { + $auditOnly=$false + @{LastAttemptUtc=[DateTimeOffset]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content (Join-Path $InstallPath 'region-state.json') + Invoke-VpnRecovery + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'blocks recovery if the cooldown state is corrupt' { + 'invalid json' | Set-Content (Join-Path $InstallPath 'region-state.json') + { Invoke-VpnRecovery } | Should Throw + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[0] -in @('set','connect') } + } + It 'rejects region IDs containing shell syntax' { + $c.PiaRegions=@('region-a','bad;command') + { Invoke-VpnRecovery } | Should Throw + } +} +Describe 'Saved startup binding' { + It 'requires the configured VPN interface in the selected profile' { + $path=Join-Path $TestDrive 'qbt.ini' + 'Connection\Interface=VPN adapter' | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='VPN adapter'} + Test-StartupBinding | Should Be $true + } + It 'rejects the unrestricted interface' { + $path=Join-Path $TestDrive 'unbound.ini' + 'Connection\Interface=' | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='VPN adapter'} + Test-StartupBinding | Should Be $false + } +} + +Describe 'VPN exit IP and local tunnel readiness' { + BeforeEach { + $c=[pscustomobject]@{VpnInterface='test-tunnel'} + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Connected' } else { '203.0.113.8' } } + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Up';InterfaceIndex=42}) } + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Preferred'}) } + } + It 'accepts a private tunnel IP different from the public VPN exit IP' { + Test-VpnReady | Should Be $true + Assert-MockCalled Get-NetIPAddress -Times 1 -Exactly -Scope It -ParameterFilter { $InterfaceIndex -eq 42 -and $AddressFamily -eq 'IPv4' } + } + It 'rejects a disconnected VPN even when the adapter is up' { + Mock Invoke-Pia { 'Reconnecting' } + Test-VpnReady | Should Be $false + } + It 'rejects a down configured adapter' { + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Disconnected';InterfaceIndex=42}) } + Test-VpnReady | Should Be $false + } + It 'rejects a missing configured adapter' { + Mock Get-NetAdapter { @() } + Test-VpnReady | Should Be $false + } + It 'rejects an adapter with only a link local address' { + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='169.254.1.2';AddressState='Preferred'}) } + Test-VpnReady | Should Be $false + } + It 'rejects an unready tunnel address' { + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Tentative'}) } + Test-VpnReady | Should Be $false + } + It 'accepts healthy tunnel checks when public exit IP telemetry would be Unknown' { + Mock Invoke-Pia { if ($Arguments[1] -eq 'connectionstate') { 'Connected' } else { 'Unknown' } } + Test-VpnReady | Should Be $true + Assert-MockCalled Invoke-Pia -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments[1] -eq 'vpnip' } + } +} + +Describe 'Windows qBittorrent interface name and ID' { + BeforeEach { + $path=Join-Path $TestDrive 'windows-binding.ini' + @('Connection\InterfaceName=test-tunnel','Connection\Interface=iftype53_12345') | Set-Content $path + $c=[pscustomobject]@{QbtSettingsPath=$path;VpnInterface='test-tunnel'} + } + It 'accepts a matching saved display name with a distinct internal ID' { + Test-StartupBinding | Should Be $true + } + It 'compares the live API binding to the saved internal ID' { + Test-LiveBinding ([pscustomobject]@{current_network_interface='iftype53_12345'}) | Should Be $true + } + It 'rejects an unrelated live internal ID' { + Test-LiveBinding ([pscustomobject]@{current_network_interface='iftype53_99999'}) | Should Be $false + } + It 'rejects a saved name that does not match the configured adapter' { + $c.VpnInterface='different-tunnel' + Test-StartupBinding | Should Be $false + } + It 'rejects a name without an internal ID' { + 'Connection\InterfaceName=test-tunnel' | Set-Content $path + Test-StartupBinding | Should Be $false + } + It 'rejects duplicate binding names' { + 'Connection\InterfaceName=another-tunnel' | Add-Content $path + Test-StartupBinding | Should Be $false + } +} + +Describe 'VPN readiness fail-closed diagnostics' { + BeforeEach { + $c=[pscustomobject]@{VpnInterface='test-tunnel'} + Mock Write-GuardLog {} + Mock Invoke-Pia { 'Connected' } + Mock Get-NetAdapter { @([pscustomobject]@{Name='test-tunnel';Status='Up';InterfaceIndex=42}) } + Mock Get-NetIPAddress { @([pscustomobject]@{IPAddress='10.8.0.2';AddressState='Preferred'}) } + } + It 'reports a disconnected VPN and blocks readiness' { + Mock Invoke-Pia { 'Reconnecting' } + Test-VpnReady | Should Be $false + Assert-MockCalled Write-GuardLog -Times 1 -Exactly -Scope It -ParameterFilter { $Message -match 'connection state is Reconnecting' } + } + It 'blocks readiness when the configured interface is empty' { + $c.VpnInterface='' + Test-VpnReady | Should Be $false + Assert-MockCalled Get-NetAdapter -Times 0 -Exactly -Scope It + } + It 'blocks readiness when only an unrelated Up adapter exists' { + Mock Get-NetAdapter { @([pscustomobject]@{Name='Ethernet';Status='Up';InterfaceIndex=2}) } + Test-VpnReady | Should Be $false + } + It 'fails when local adapter inspection throws' { + Mock Get-NetAdapter { throw 'adapter inspection failed' } + { Test-VpnReady } | Should Throw + } + It 'fails when local address inspection throws' { + Mock Get-NetIPAddress { throw 'address inspection failed' } + { Test-VpnReady } | Should Throw + } +} diff --git a/tests/QbtGuard.Logging.Tests.ps1 b/tests/QbtGuard.Logging.Tests.ps1 new file mode 100644 index 0000000..257f896 --- /dev/null +++ b/tests/QbtGuard.Logging.Tests.ps1 @@ -0,0 +1,67 @@ +# Exercise production logging, not the mock used by isolated control tests. +$engine=Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +Describe 'Production logger preserves control return values' { + BeforeEach { + foreach ($name in @('Write-GuardLog','Invoke-GuardAction')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $log=Join-Path $TestDrive 'control.log' + } + It 'returns exactly one false value when audit mode logs a proposed action' { + $auditOnly=$true + $values=@(Invoke-GuardAction 'fixture' { throw 'Must not run' }) + $values.Count | Should Be 1 + $values[0] | Should Be $false + (Get-Content $log -Raw) | Should Match 'WOULD fixture' + } + It 'returns exactly one true value when enforcement logs success' { + $auditOnly=$false + $values=@(Invoke-GuardAction 'fixture' { 'discard action output' }) + $values.Count | Should Be 1 + $values[0] | Should Be $true + } + It 'does not return a success-stream value from logging alone' { + @(Write-GuardLog 'fixture').Count | Should Be 0 + } +} + +Describe 'Quiet completion heartbeat' { + BeforeEach { + foreach ($name in @('Write-GuardLog','Write-GuardCompletion')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $log=Join-Path $TestDrive ([guid]::NewGuid().ToString() + '.log') + $now=[datetime]'2026-10-16T12:00:00' + } + It 'writes a heartbeat for a missing log without returning pipeline output' { + @(Write-GuardCompletion 'ENFORCE completed' $true $now).Count | Should Be 0 + (Get-Content $log -Raw) | Should Match 'HEARTBEAT: ENFORCE completed' + } + It 'leaves a recent log untouched' { + [IO.File]::WriteAllText($log,'previous action') + (Get-Item $log).LastWriteTime=$now.AddHours(-1) + Write-GuardCompletion 'ENFORCE completed' $true $now + (Get-Content $log -Raw) | Should Be 'previous action' + } + It 'writes after the previous calendar day cutoff even under 24 hours of inactivity' { + [IO.File]::WriteAllText($log,'old') + (Get-Item $log).LastWriteTime=[datetime]'2026-10-15T00:00:30' + Write-GuardCompletion 'ENFORCE completed' $true ([datetime]'2026-10-16T00:00:00') + (Get-Content $log -Raw) | Should Match 'HEARTBEAT' + } + It 'does not write at the exact cutoff' { + [IO.File]::WriteAllText($log,'previous') + (Get-Item $log).LastWriteTime=$now.Date.AddDays(-1).AddMinutes(1) + Write-GuardCompletion 'ENFORCE completed' $true $now + (Get-Content $log -Raw) | Should Be 'previous' + } + It 'retains full summaries when quiet is disabled' { + Write-GuardCompletion 'AUDIT completed' $false $now + (Get-Content $log -Raw) | Should Match 'AUDIT completed' + } +} diff --git a/tests/QbtGuard.Recovery.Tests.ps1 b/tests/QbtGuard.Recovery.Tests.ps1 new file mode 100644 index 0000000..a4e71e7 --- /dev/null +++ b/tests/QbtGuard.Recovery.Tests.ps1 @@ -0,0 +1,99 @@ +# Mocked asynchronous PIA state transitions; no real VPN calls or waits. +$engine=Join-Path (Split-Path -Parent $PSScriptRoot) 'QbtGuard.ps1' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($engine,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +function Invoke-Pia { param($Arguments) } +function Write-GuardLog { param($Message) } +function Test-VpnReady { } +function Get-ForwardedPort { } +Describe 'Asynchronous PIA recovery' { + BeforeEach { + foreach ($name in @('Get-Setting','Invoke-GuardAction','Invoke-VpnRecovery')) { + $definition=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true) | Select-Object -First 1 + . ([scriptblock]::Create($definition.Extent.Text)) + } + $InstallPath=Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory $InstallPath | Out-Null + $c=[pscustomobject]@{EnableVpnRecovery=$true;PiaRegions=@('region-a','region-b');RegionCooldownMinutes=10} + $auditOnly=$false + $script:piaState='Disconnecting' + $script:piaRegion='region-a' + $script:stateReads=0 + $script:settleAt=4 + $script:connectCount=0 + $script:connectSucceedsAt=1 + $script:commandStates=New-Object 'System.Collections.Generic.List[string]' + Mock Start-Sleep {} + Mock Write-GuardLog {} + Mock Test-VpnReady { $true } + Mock Get-ForwardedPort { 12345 } + Mock Invoke-Pia { + if ($Arguments[0] -eq 'get') { + switch ($Arguments[1]) { + 'connectionstate' { + $script:stateReads++ + if ($script:piaState -eq 'Disconnecting' -and $script:stateReads -ge $script:settleAt) { $script:piaState='Disconnected' } + return $script:piaState + } + 'portforward' { return 'Failed' } + 'region' { return $script:piaRegion } + } + } + if ($Arguments[0] -eq 'disconnect') { + $script:commandStates.Add('disconnect:'+$script:piaState) + $script:piaState='Disconnecting'; return + } + if ($Arguments[0] -eq 'set') { + $script:commandStates.Add('set:'+$script:piaState) + $script:piaRegion=$Arguments[2]; return + } + if ($Arguments[0] -eq 'connect') { + $script:commandStates.Add('connect:'+$script:piaState) + $script:connectCount++ + if ($script:connectCount -ge $script:connectSucceedsAt) { $script:piaState='Connected' } + } + } + } + It 'waits for Disconnected before setting the region and connecting' { + Invoke-VpnRecovery + ($script:commandStates -join ',') | Should Be 'set:Disconnected,connect:Disconnected' + $script:piaRegion | Should Be 'region-b' + Assert-MockCalled Write-GuardLog -Times 1 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'retries a connect request that returns success but leaves PIA Disconnected' { + $script:connectSucceedsAt=2 + Invoke-VpnRecovery + $script:connectCount | Should Be 2 + ($script:commandStates -join ',') | Should Not Match 'connect:Disconnecting' + } + It 'withholds region and connect commands if disconnect never settles' { + $script:settleAt=9999 + { Invoke-VpnRecovery } | Should Throw 'did not finish disconnecting' + $script:commandStates.Count | Should Be 0 + } + It 'explicitly disconnects a connected tunnel after forwarding failure' { + $script:piaState='Connected' + Invoke-VpnRecovery + $script:commandStates[0] | Should Be 'disconnect:Connected' + $script:commandStates[1] | Should Be 'set:Disconnected' + } + It 'bounds connect retries and reports a timeout without success' { + $script:connectSucceedsAt=9999 + { Invoke-VpnRecovery } | Should Throw 'timed out' + $script:connectCount | Should Be 4 + Assert-MockCalled Write-GuardLog -Times 0 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'does not declare recovery verified without a forwarded port' { + Mock Get-ForwardedPort { $null } + { Invoke-VpnRecovery } | Should Throw 'timed out' + Assert-MockCalled Write-GuardLog -Times 0 -Exactly -Scope It -ParameterFilter { $Message -eq 'VPN recovery verified' } + } + It 'does not mutate PIA or wait in audit mode' { + $auditOnly=$true + Invoke-VpnRecovery + $script:commandStates.Count | Should Be 0 + Assert-MockCalled Start-Sleep -Times 0 -Exactly -Scope It + Test-Path (Join-Path $InstallPath 'region-state.json') | Should Be $false + } +} diff --git a/tests/QbtGuard.Tests.ps1 b/tests/QbtGuard.Tests.ps1 index 7a207ca..f82a89d 100644 --- a/tests/QbtGuard.Tests.ps1 +++ b/tests/QbtGuard.Tests.ps1 @@ -89,13 +89,3 @@ Describe 'qBtGuard torrent path validation' { } } -Describe 'qBtGuard read-only source safeguards' { - It 'contains no torrent mutation API endpoints' { - $source = Get-Content -LiteralPath $engine -Raw - $source | Should Not Match '/api/v2/torrents/(delete|add|pause|resume|recheck|renameFile|setLocation)' - } - It 'does not invoke filesystem deletion or PIA controls' { - $source = Get-Content -LiteralPath $engine -Raw - $source | Should Not Match '(?i)\b(Remove-Item|DeleteFile|Stop-Process|Start-Process|piactl)\b' - } -} diff --git a/tests/QbtGuard.Uninstall.Tests.ps1 b/tests/QbtGuard.Uninstall.Tests.ps1 index 0ec8497..a95ff18 100644 --- a/tests/QbtGuard.Uninstall.Tests.ps1 +++ b/tests/QbtGuard.Uninstall.Tests.ps1 @@ -39,3 +39,4 @@ Describe 'qBtGuard uninstaller ownership safeguards' { Assert-MockCalled Unregister-ScheduledTask -Times 0 -Exactly -Scope It } } + diff --git a/tests/README.md b/tests/README.md index 9cc4b39..d522b9c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,15 @@ -# Test plan (not yet automated) - -The preview is **not validated on other machines**. Before public release, test on a clean Windows 10/11 VM with qBittorrent 4.3.6 and Web UI API v2. - -1. Installation with and without a scheduled task; repeat installation; uninstallation. -2. Correct DPAPI authentication and wrong password, server unavailable, TLS failure. -3. Completed and incomplete torrents, nested directories, extensionless and prohibited files. -4. Valid EBML header, invalid header, unreadable file, missing UNC share. -5. Path traversal attempts, including absolute paths, dot-dot segments and alternate separators. -6. Scheduled task running under the intended user and while logged out. -7. Verify no delete, PIA, or qBittorrent lifecycle actions occur. -8. Scan all source and commit history for secrets before changing visibility to public. +# Windows QA + +Run in Windows PowerShell 5.1 with Pester 3.4.0: + +```powershell +Import-Module Pester -RequiredVersion 3.4.0; Invoke-Pester -Script .\tests +``` + +Tests use disposable local fixtures, DPAPI test credentials and mocked API/process/VPN/task commands. They never contact real qBittorrent or PIA. They cover path traversal and reparse points, header findings/privacy, API failure handling, shared audit action gating, deletion policy and hash targeting, disabled/unhealthy restart, remote-local separation, forwarding-port validation and recovery cooldown. + +CI checks syntax and runs these tests on Windows. Real VPN/interface/profile behavior, clean installation, scheduler registration and destructive policies still require isolated Windows integration QA using disposable downloads. A mocked test pass is not permission to deploy enforcement. + +## Pilot evidence + +The tested engine at `f9fc69e96df5a8fb2137c1a1a2e75e1bb138f87f` passed 84 tests with zero failures or skipped tests in Windows CI and on the initial Windows system. Live action tests and the October 15–19 observation are documented in [QA.md](../QA.md). This is one-machine integration evidence, not proof of compatibility across other versions or installations.